Text view adventures, Part 4

December 14, 2018

By Jeff Johnson

This is my last scheduled text view adventure. There may be more, but they're unscheduled. And let's face it, the best adventures are unscheduled. So stay tuned, the best is yet to come. Or not. If you need to catch up on my previous adventures, you can read Part 1, Part 2, and Part 3. There will be a quiz at the end of the universe.

I want to talk about UITextView scrolling. No, that's not quite right. I don't want to talk about UITextView scrolling. Nonetheless, I feel that I have to talk about UITextView scrolling, because it's more difficult than one would expect. Suppose that you wrote a messaging app, and you wanted to programmatically scroll a newly received message into view. On macOS, this would be straightforward: you call -[NSTextView scrollRangeToVisible:] with the range of the new message text. According to the API documentation, this method "Scrolls the receiver in its enclosing scroll view so the first characters of aRange are visible." The qualifier "first characters" is important, because the range of text could be larger than will fit in the visible area of the text view. In a messaging app, you wouldn't want the user to miss parts of a long message, so you'd probably want to place the beginning of the message on the screen and let the user scroll down manually to see the rest of the message that doesn't fit on the screen. NSTextView does this by default. Hooray for AppKit!

What about UIKit? According to the API documentation for -[UITextView scrollRangeToVisible:], this method "Scrolls the receiver until the text in the specified range is visible." Huh. Notice what's missing? No mention of the "first characters" this time. So what happens when you call scrollRangeToVisible: with a range that doesn't fit in the visible area of the UITextView? It scrolls to the end of the text range, not the beginning. Ugh.

You might think that you can solve this problem simply as follows: take the contentSize.height of the UITextView before adding new text, then append the new message, and finally set the contentOffset of the UITextView using the old content height as the y-coordinate. Shouldn't this make the text view scroll so that the beginning of the new message is at the top of the visible area? I tried this procedure, but the result was that the text view would consistently scroll down one line too far. The scrolling was wrong even when I set the contentInset, textContainerInset, and textContainer lineFragmentPadding all to zero. Nothing seemed to help.

I was discussing the problem with my friend Nick Lockwood, and Nick discovered that the problem only occurred when there was a newline at the end of the text. As a matter of fact, my code was adding a newline at the end of every message. However, even though I could work around the problem by refactoring my code to eliminate the newlines, I wasn't fond of such a workaround, because the newlines made it visually clearer to the user that all of the message text has come to the end at the bottom of the text view. I also couldn't understand why the newlines were causing a problem. So I continued to dig into the UITextView API, where I ultimately found the answer: -[NSLayoutManager extraLineFragmentRect]. The best explanation of it is not in the API documentation but rather in the comments of the NSLayoutManager.h header file:

The extra line fragment is used for displaying the line at the end of document when the last character in the document causes a line or paragraph break. Since the extra line is not associated with any glyph inside the layout manager, the information is handed [sic] separately from other line fragment rects.

The contentSize of the UITextView includes the extraLineFragmentRect. But this means that the height of the old contentSize was not the height of the old message text. The height of the old message text was the height of the contentSize minus the height of the extraLineFragmentRect. You need to factor the extraLineFragmentRect into the calculations, which was why my original solution was off by one line.

One more thing. Besides the unexpected, undesirable scrollRangeToVisible: behavior, UITextView has another major issue with scrolling: it doesn't draw the text synchronously after editing the text storage, which means that you can't get an accurate contentSize after you append new text. I am not kidding. I am not lying. This is true. As a consequence, you need to delay programmatic scrolling until text drawing is done, so that you can get the new contentSize.

Below is some sample code to programmatically scroll a new message into view. I can't promise that it's the best code, but it does work, and working code is the best code, so… maybe I can promise that it's the best code.

BOOL scrollToBottom = YES;
NSUInteger oldLength = textStorage.length;
CGSize oldContentSize = textView.contentSize;
CGFloat oldContentHeight = contentSize.height;
CGSize visibleSize = textView.visibleSize;
CGFloat visibleHeight = visibleSize.height;
if (oldContentHeight > visibleHeight) {
  CGFloat lastPageOriginY = oldContentHeight - visibleHeight;
  CGPoint contentOffset = textView.contentOffset;
  CGFloat contentOffsetY = contentOffset.y;
  if (contentOffsetY < lastPageOriginY) {
    CGFloat distanceToBottom = lastPageOriginY - contentOffsetY;
    // Don't autoscroll if we're not already near the bottom.
    if (distanceToBottom > 8.0)
      scrollToBottom = NO;
  }
}

// Edit textStorage here
if (!scrollToBottom)
  return;

dispatch_time_t when = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC));
dispatch_after(when, dispatch_get_main_queue(), ^{
  NSUInteger newLength = textStorage.length;
  if (newLength <= oldLength)
    return;
  CGSize newContentSize = textView.contentSize;
  CGFloat newContentHeight = newContentSize.height;
  CGFloat delta = newContentHeight - oldContentHeight;
  if (delta <= 0.0)
    return;
  CGSize visibleSize = textView.visibleSize;
  CGFloat visibleHeight = visibleSize.height;
  CGFloat lastPageOriginY = newContentHeight - visibleHeight;
  if (lastPageOriginY <= 0.0)
    return;
  CGPoint contentOffset = textView.contentOffset;
  CGFloat contentOffsetY = contentOffset.y;
  if (contentOffsetY >= lastPageOriginY)
    return;
  if (delta > visibleHeight) {
    CGRect extraLineFragmentRect = textView.layoutManager.extraLineFragmentRect;
    CGFloat extraLineFragmentHeight = extraLineFragmentRect.size.height;
    CGFloat newContentOffsetY;
    if (extraLineFragmentHeight > oldContentHeight)
      newContentOffsetY = 0.0;
    else if (extraLineFragmentHeight > 0.0)
      newContentOffsetY = oldContentHeight - extraLineFragmentHeight;
    else
      newContentOffsetY = oldContentHeight;
    [textView setContentOffset:CGPointMake(0.0, newContentOffsetY) animated:YES];
    [textView flashScrollIndicators];
  } else {
    NSRange range = NSMakeRange(oldLength, newLength - oldLength);
    [textView scrollRangeToVisible:range];
  }
});

In the old days, we used to say that all problems can be solved with performSelector:withObject:afterDelay:. Now we say that dispatch_after is the new performSelector:withObject:afterDelay:.

A slight complication is that I ignore -[UITextView textContainerInset] in my code by setting it to UIEdgeInsetsZero. The default value is (8, 0, 8, 0), so you'd have to tweak the code slightly to account for the inset if necessary. I'll leave that as an exercise for the reader. Work off some of those holiday calories.

We've come to the end of my scheduled text view adventures. I hope that you enjoyed them as much as I did. It's a little late for Thanksgiving, but I'd like to thank Apple's engineers for inspiring these adventures. Just please, dear engineers, don't make me write any more. #cancelsouthpark