16
votes

Problem

I need to understand how TextKit works and how I can use it to build a text editor. I need to figure out how to draw ONLY the visible text the end-user interacts with or determine how I would go about lazily applying attributes to the visible text only without applying attributes to the entire changed range of text in the processEditing method.

Background

iOS 7 came out with TextKit. I have a tokenizer and code that fully implements TextKit (refer to Apple's TextKitDemo project -- a link is provided below)... and it works. However, it is REALLY slow. As text is parsed, by the NSTextStorage, it requires you to colorize the ENTIRE range of the edited text, in the processEditing method, on the same thread. Offloading the work to a thread doesn't help. It's simply too slow. I got to the point where I can re-attribute only the modified ranges, but if the range is too big the process will be slow. In some conditions the entire document could be invalidated after a change has been made.

Here are some ideas I have. Please let me know if any of these will work or maybe nudge me in the right direction.

1) Multiple NSTextContainers

Reading the docs it appears that I can add multiple NSTextContainers within an NSLayoutManager. I'm assuming that by doing this I should be able to define not only the number of lines that can be drawn in the NSTextContainer, but I should also be able to know which NSTextContainer is visible to the end-user. I know that if I go this route I will need to invest a LOT of time just to see if it's feasible. Initial testing suggests that you only need one NSTextContainer. So I would have to subclass NSLayout or create a wrapper where the layout manager determines which text goes into which text container. Yuck. Also, I have NO idea how TextKit lets me know that it is time to draw a particular NSTextContainer... maybe this isn't how it works!

2) Invalidating Ranges w/ NSLayoutManager

Invalidating the layoutManager using the invalidateLayoutForCharacterRange:actualCharacterRange:. But what does this actually do and how will it offload the text attribution phase? When does it let me know that a particular text needs to be highlighted? Also, I see that the NSLayoutManager will lazily draw glyphs... how? when? How does this help me? How do I tap into this call so that I can attribute the backing string before it actually lays out the text?

3) Over-riding the NSLayoutManager drawGlyphsForGlyphRange:atPoint: method.

I really do not want to do this. That being said, in Mac OS X, NSAttributedStrings have this concept of temporary attributes where the style information is used only for presentation. This speeds up the process of highlighting GREATLY! The problem is, it doesn't exist in the iOS 7 TextKit framework (or it's there and I just don't know about it). I believe that by over-riding this method it will give me the same type of speeds you would get by using temporary attributes... as I could answer all of the layout, color and formatting questions in this method without ever touching the NSTextStorage attributed string. The only problem is, I don't know how this method works in relation to other methods provided in the NSLayoutManager class. Does it keep state of the width and height? Does it modify the size of the NSTextContainer when it's too small? Also, it only draws glyphs for characters that have been added in the text buffer. It doesn't re-draw the whole screen. only a tiny section of it... and that's perfectly fine. I have some ideas of how I could work with this... but I really have no desire to layout the glyphs. That is WAY too much work and I haven't found a good example that does this.

I would greatly appreciate any help you have to offer.

As a thank you, I'm listing all of the frameworks and references I have used over the last few years that have helped me get to where I am now in the hopes that they are helpful to you.

Syntax highlighting frameworks:

Resources:

Most of these frameworks are the same. They either do not account for context switching (or you have to write the wrapper to provide context) for ranges or they do not repair context ranges as a user modifies the text (such as strings, multi-line comments, etc.). The last requirement is VERY important. Because if a tokenizer can't determine which ranges are affected by a change you will end up having to parse and attribute the entire string again. The only exception to this is the Crimson Editor. The issue with this tokenizer is that it doesn't save state at the time of tokenizing. At draw time, the algorithm uses the tokens to determine the state of drawing. It starts from the top of the document, up until it gets to the visible range of text. Needless to say, I have optimized this by cacheing the state of the document in certain parts of the document.

The other issue is that the frameworks do not follow the same MVC pattern that Apple does -- which is to be expected. The frameworks that have a complete working editor all use hooks, provided by the API they are built on (ie GTK, Windows, etc.), that provides them with information of where and when to draw to what part of the screen. In my case, TextKit appears to require you to attribute the entire changed range in processEditing.

Maybe my observations are wrong. (I hope they are!!) Maybe, ParseKit for instance, will work for what I need it to do and I simply don't understand how to use it. If so, please let me know! And thanks again!

1
If anyone tries to implement the above suggestions will you let me know how it went? I would love to hear your findings! Thanks!PeqNP
I forgot to add one thing. I've also tried overriding the NSLayoutManager attributesAtIndex:effectiveRange:. I'm assuming this returns an NSDictionary with key/value pairs that consist of NSForegroundColorAttributeName/UIColor, etc. However, every time I attempt to return a basic dictionary with this key/value pair, the program hangs... but, this may be the place where I could, potentially, provide the attributes lazily... this is assuming that the attributes are queried lazily. I can only guess that I would need to invalidate the range of text that changed in order for this to work as well.PeqNP
I think I may have it; if I can determine the range of text that is visible to the user I could apply attributes ONLY to that range in processEditing and mark the invalidated ranges, before and after the visible text, with a custom attribute (or a list of NSRanges, which ever). I would then offload the remaining work, for the invalidated text only, in a separate process.PeqNP

1 Answers

9
votes

I figured it out. I used none of the suggestions above. That being said, the performance I am getting now is simply incredible. Keep in mind that YMMV. The way you tokenize and cache meta-data about your string may be different than me. However, I was able to type in a 1400 line PHP file and it took only 0.015 seconds for any one change to complete. Simply incredible.

Here is the approach I took:

My UIViewController is a delegate to UITextViewDelegate and UIScrollViewDelegate.

When UITextViewDelegate.textViewDidChange: is called I determine which range of text is currently visible to the end-user. I did this by using my existing sub-classed UITextView and adding this method to it:

- (NSRange)visibleRangeOfText
{
    CGRect bounds = self.bounds;
    UITextPosition *start = [self characterRangeAtPoint:bounds.origin].start;
    UITextPosition *end = [self characterRangeAtPoint:CGPointMake(CGRectGetMaxX(bounds), CGRectGetMaxY(bounds))].end;
    return NSMakeRange([self offsetFromPosition:self.beginningOfDocument toPosition:start],
                   [self offsetFromPosition:start toPosition:end]);
}

After that, I pass the range to a subclassed NSTextStorage object, where it will then perform the magic to determine which lines need to be highlighted.

The same goes for the UIScollViewDelegate method calls. Depending on which part of the view is being viewed I pass in the visible range to my subclassed NSTextStorage call and it determines if the lines have already been attributed, etc.

I realize I'm leaving a lot up to the reader. I ended up using what I currently had and tweaked it a bit to work with the above implementation.

I wanted to share some of my discoveries that I found interesting while implementing this:

1) If you attempt to highlight any text ABOVE the current line, where the cursor is resting, you may see that the cursor "jumps" up within the view, and then settles back to the position where it was originally. I am almost positive this is caused by the NSTextStorage.processEditing method call. I was able to get it to where the system only highlights the line that was modified... so this issue is gone now.

2) Originally I did this to prevent the cursor from jumping around:

NSRange selectedRange = [textView selectedTextRange];
[textView setScrollEnabled:NO];
NSRange visibleRange = [textView visibleRangeOfText];
[textStorage applyAttributesToRange:visibleRange];
[textView setScrollEnabled:YES];

It worked... but the [textView setScrollEnabled:NO] call made a MASSIVE hit to performance. It took nearly 3/4 of a second for that command alone to finish on a 1400 line file. I'm not sure what causes it to be slow but I thought it was worth mentioning.