Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Subdivide a text view's attributed string into (referable) sections

I'm trying to implement a very simple text editor that should work both with NSTextView on macOS and UITextView on iOS. This text editor has a toolbar button "Section Break" that inserts a new section at the current cursor position every time it is clicked. A section should be:

  1. Visually identifiable as a section (by adding visual separators between two subsequent sections and possibly adding some vertical whitespace),

  2. Referable. The user should be able to see a list of all sections in the editor and by clicking an item in that list, the text view should immediately scroll to the beginning of that section.

In another question I asked how to solve the first problem and unfortunately, I haven't found an answer on that part yet (even though there is a solution that works on macOS only).

However, this question focuses on the second aspect:
How can I maintain a list of all sections in my text view where each section keeps an accurate reference to the related text?

The complexity about this task is that the user can copy & paste or simply edit any part of the text at any time. Thus, I cannot keep a simple array of paragraph numbers or something like that.

Screenshot of several sections in a text view


What I've tried and why this appears to be such a difficult task:

  1. One idea I've had was to subclass NSTextStorage and use an array of mutable attributed strings as its internal storage. I would then use a special subclass of NSTextAttachment and use it as a section break indicator inside my text storage. The problem with that is that the text view only calls the following methods whenever the user edits text:

    func replaceCharacters(in range: NSRange, 
                           with str: String)
    

    and

    func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, 
                         range: NSRange)
    

    The first method is only passed the plain string without any attributes, the second method only gets the attributes. This means that in the first method, I cannot tell if the replacement character is actually supposed to be a section break or not, so I cannot decide at that point where to create a new element in my internal text storage array.

    In the second method, I don't know whether the user actually added new text (in which case I would have to add a new element in my text storage array for each section break attribute) or if the user simply changed some attributes of existing text (in which case the new array elements have already been created previously).

  2. I've also considered the idea of using multiple text views inside a table view or stack view. However, that doesn't work because it would keep the user from selecting (and deleting) text across multiple subsequent sections.

  3. Finally, I've tried subclassing NSLayoutManager, but the documentation on that is really thin and it seems to be the wrong place for me. (After all, the layout manager's responsibility is the layout of the text, not to keep track of its structure.)

Any ideas?

like image 682
Mischa Avatar asked Mar 20 '19 14:03

Mischa


2 Answers

How can I maintain a list of all sections in my text view where each section keeps an accurate reference to the related text?

Start by deciding what a section is. Intuitively, it seems like a block of contiguous text, several of which are strung together to form a complete document. But try to go more deeply than that: Does all the text in a section have the same style? The same margins? Do you really need to keep track of sections at all, or would it work as well to just keep track of section breaks?

Next, since you've already decided to buy into TextKit by using NSTextView and UITextView, and since you have needs that go beyond the most common "a view with some editable text in it" use case for those classes, you need to learn more about how the underlying classes fit together. The main classes are NSTextStorage, NSTextContainer, NSLayoutManager, and of course NSTextView (or UITextView).

Understanding how the TextKit classes work and what facilities they provide to you will help you figure out how to implement your "section" concept. Here are a couple examples of ways you could go:

  • Build your own text view. Much of the work that a text view does is really done by the other classes: text container, layout manager, and text storage. You could build your own view that contains a series of text containers, so that each section is represented by a different container, each with its own layout manager and storage objects. This is obviously the most work, but it would afford a lot of control over how sections are displayed. Also, if you build your view to work on both platforms, you won't have to worry about differences between UITextView and NSTextView.

  • Let the text view do all the work. A lightweight implementation of sections could involve setting up a delegate for either the text view or text storage object. Both of those call delegate methods when editing occurs, so if your delegate maintains a list of indices or ranges that represent sections, it can use those methods to update its section boundary data. You'll still have to figure out how to show the section boundaries visually, but that could be as simple as inserting an image at each section boundary.

like image 149
Caleb Avatar answered Nov 12 '22 17:11

Caleb


Assuming that you’re using a NSTextAttachment subclass it should be fairly easy to accomplish both task, but let’s focus on the second:

Your first solution of subclassing NSTextStorage is good, but it expose the storage to a lot of unwanted logic, my suggestion is just to have your (NS|UI)TextViewDelegate to implement the func textDidChange(_ notification: Notification) and when the text change you can use the text storage to enumerate your attachments and save a list of ranges to reference later:

func textDidChange(_ notification: Notification)
{
    self.textView.textStorage?.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, self.textView.textStorage!.length), options: [], using: {
        (value:Any?, range:NSRange, stop:UnsafeMutablePointer<ObjCBool>) in
        // If the value is one of your attachment save the range in a list
    })
}

This way it doesn’t matter what the user is doing (writing, copy/pasting, etc…) you will always have a list of valid ranges containing your separator :)

This is just an example, your logic for re-building the list can be more refined, but I hope this can get you started.

like image 2
Matteo Avatar answered Nov 12 '22 18:11

Matteo