Wednesday, March 28, 2012

Syntax Highlighting in iOS

The Problem

One of the controls included with the UIKit framework of iOS is the UITextView. It handles all text editing tasks including line wrapping, copy and paste operations, and undo. The only thing it lacks is text styling. But starting from iOS 4.0 Apple added the CoreText framework. This framework gives you the ability to draw text with different styles and colors. The problem is integrating the text styling features of CoreText with the text editing features of UIKit.

The only successful reports of doing syntax highlighting involve either rewriting all editing features of an UITextView like in OmniUI, or using a UIWebView with JavaScript to handle the highlighting as described here and here.

I found a way of reusing a UITextView with custom drawing using CoreText.

The Solution

Let us start by creating a view that draws an attributed string. An NSAttributedString is what CoreText uses to draw text that has styling information like colors. Here is our class definition:

#import <UIKit/UIKit.h>

@interface AttributedTextView : UIView
@property (strong, nonatomic) NSAttributedString* string;
@end
Pretty simple. And this is the implementation of the drawing code:
- (void)drawRect:(CGRect)rect {
  // flip the coordinate system
  CGContextRef context = UIGraphicsGetCurrentContext();
  CGContextSetTextMatrix(context, CGAffineTransformIdentity);
  CGContextTranslateCTM(context, 0, self.bounds.size.height);
  CGContextScaleCTM(context, 1.0, -1.0);
  
  // Get the text bounds
  CGSize size = self.bounds.size;
  CGRect r = CGRectMake(MARGIN, MARGIN, size.width - 2*MARGIN, size.height - 2*MARGIN);
  
  // Get line height
  CTFontRef font = (CTFontRef)[string attribute:(id)kCTFontAttributeName atIndex:0 effectiveRange:NULL];
  CGFloat lineHeight = [[self class] lineHeight:font];
  
  // Draw lines
  CGFloat y = r.size.height - MARGIN + 2;
  NSCharacterSet* cs = [NSCharacterSet newlineCharacterSet];
  NSRange range = NSMakeRange(0, string.length);
  while (true) {
    // Find next line break
    NSRange next = [string.string rangeOfCharacterFromSet:cs options:NSLiteralSearch range:range];
    if (next.location == NSNotFound)
      break;
    
    // Keep track of ranges
    NSUInteger len = next.location - range.location;
    NSRange lineRange = NSMakeRange(range.location, len);
    range.location += len + 1;
    range.length -= len + 1;
    
    // Draw line
    if (len > 0)
      [self drawLine:lineRange offset:y context:context];
    y -= lineHeight;
  }
  
  // Draw last line
  if (range.length > 0)
    [self drawLine:range offset:y context:context];
}

- (void)drawLine:(NSRange)range offset:(CGFloat)offset context:(CGContextRef)context {
  // Get one line of text
  NSAttributedString* s = [string attributedSubstringFromRange:range];
  
  // Draw line
  CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)s);
  CGContextSetTextPosition(context, MARGIN, offset);
  CTLineDraw(line, context);
  CFRelease(line);
}
An important method here is lineHeight: which computes the number of pixels to move down after each line. This has to match the line height of the UITextView exactly or else the selection markers and the caret will be off when editing. From trial an error I got this, but you may have to adjust for different fonts:
+ (CGFloat)lineHeight:(CTFontRef)font {
  CGFloat ascent = CTFontGetAscent(font);
  CGFloat descent = CTFontGetDescent(font);
  CGFloat leading = CTFontGetLeading(font);
  return ceilf(ascent + descent + leading);
}

Now for the actual text view. We have to inherit from UITextView, add our custom AttributedTextView as a subview and make sure the text view is refreshed every time the text changes:

- (void)setup {
  // The internal delegate notifies the AttributedTextView of text changes
  internalDelegate = [[HighlightingTextViewDelegate alloc] init];
  self.delegate = internalDelegate;
  
  // The syntax highlighter is in charge of converting an NSString to an NSAttributedString
  syntaxHighlighter.font = self.font;
  
  // Create the AttributedTextView and add as a subview, covering the default text
  attributedTextView = [[AttributedTextView alloc] init];
  attributedTextView.string = [syntaxHighlighter highlight:self.text];
  [self addSubview:attributedTextView];
  
  for (UIView* view in self.subviews) {
    if ([view isKindOfClass:NSClassFromString(@"UIWebDocumentView")]) {
      internalDocumentView = view;
      break;
    }
  }
}

- (void)layoutSubviews {
  [super layoutSubviews];
  attributedTextView.bounds = internalDocumentView.bounds;
  attributedTextView.center = internalDocumentView.center;
}

- (void)setNeedsDisplay {
  [super setNeedsDisplay];
  attributedTextView.string = [syntaxHighlighter highlight:self.text];
  [attributedTextView setNeedsDisplay];
}

- (void)setText:(NSString *)text {
  [super setText:text];
  attributedTextView.string = [syntaxHighlighter highlight:self.text];
}
@interface HighlightingTextViewDelegate : NSObject 
@end

@implementation HighlightingTextViewDelegate
- (void)textViewDidChange:(UITextView *)textView {
  [textView setNeedsDisplay];
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
  [scrollView setNeedsDisplay];
}
@end

Edit: Make sure that the font used in the NSAttributedString returned by the syntax highlighter matches the font set in the TextView. Your highlight: method should have something like this:

- (NSAttributedString*)highlight:(NSString*)text {
  if (text == nil)
    return nil;
  
  NSMutableAttributedString* string = [[NSMutableAttributedString alloc] initWithString:text];
  NSUInteger length = [string length];
  NSRange wholeRange = NSMakeRange(0, length);
  
  // The font has to match what is set for the text vuew
  CTFontRef ctFont = CTFontCreateWithName((__bridge CFStringRef)_font.familyName, _font.pointSize, NULL);
  [string addAttribute:(id)kCTFontAttributeName
                 value:(__bridge id)ctFont
                 range:wholeRange];
  CFRelease(ctFont);
  
  // Set the default text color
  [string addAttribute:(id)kCTForegroundColorAttributeName
                 value:(__bridge id)_defaultColor.CGColor
                 range:wholeRange];
  
  // Disable ligatures if you are using a fixed-width font
  [string addAttribute:(id)kCTLigatureAttributeName
                 value:[NSNumber numberWithInt:0]
                 range:wholeRange];

  // Add more attributes to the text
}

Here is a simple iPad app with Lua highlighting in action:

You can download this project along with the rest of the code from GitHub.

Conclusion

It turns out that an UITextView can be made to render attributed text, even though it is not the cleanest implementation. Also it remains to be seen if such modifications to an UITextView will pass the App Store scrutiny. If you submit an app that uses the code do let me know whether the app gets approved or rejected.

The complete sources can be found here. It is under the Apache License version 2.0, but I would appreciate getting credit if you release an app that uses it.

9 comments:

  1. hey awesome :) thanks for your great post, i have been waiting for something like that for months...

    ReplyDelete
    Replies
    1. would you also release the syntax-hightler.m file to help us understand how to implement other languages?

      Delete
    2. I just added a demo project with the Lua syntax highlighter to GitHub. Enjoy!

      Delete
  2. Hi Alejandro Isaza,
    Thanks for your nice code. I have one problem, when I put more html code on the UITextview in your sample code, it behaves very strange when I'm typing, It doesn't type where the cursor is, it types like 4 lines over the cursor position. It happens when the text is not wrapped in the Uitextview. How can I fix this? I will appreciate your help.

    Thanks

    James

    ReplyDelete
    Replies
    1. I have this same issue, when I try to add some code the cursor gets messed up. I tried to add new detections with a different color and range, detecting symbols like "=", "(", and ")". It would not work however, and symbols were not detected when I tried implementing them in the original code either.

      I very much appreciate our tutorial, do you know how I could fix/go about this?

      Thanks!

      Delete
    2. Did you try the demo project? Give that a try. I know there are still a couple of issues like line wrapping, I'll revisit the code and try to improve it as soon as I get a chance.

      Delete
  3. Hi, i am , jhon bonachon, i have made, something like this but much more simply :P
    i would like if i can send you my code and all people see it, it work in a WebView and use javascript, i am not the real author because i get from others people that help me, however i did my code alone for my job, and would like to share with the community and help me to check my code.
    thanks in advance.

    ReplyDelete
  4. Hi Alejandro,thanks for your sharing. I have an issue when I try the sample code. I typed a tab then the next character after tab was not in right position. How can I fix this case? Thanks before :)

    Remuz

    ReplyDelete
    Replies
    1. Remuz, The best would be replacing tab characters with spaces. You can do this in the textView:shouldChangeTextInRange:replacementText: delegate method of the TextView.

      Delete