5
votes

I have been re-writing my app for iOS 7. It now uses the new TextKit API. I'm nearly complete but there is one more issue that has been causing me headache since I've started doing the transition.

Problem: When I enter text in the UITextView, on occasion, the view will scroll up hiding the cursor. It's difficult to pin-point the pattern but it appears to happen after I delete a line and the text below has to move up. But only the SECOND time I remove a line and ONLY with certain lines in the document (generally lines that have only a newline character but not always...)

I have tried using these solutions on stackoverflow:

How to stop UITextView from scrolling up when entering it

UITextView keep scrolling after every new line is inserted, so the lines are invisible (iPhone OS)

Scroll to bottom of UITextView erratic in iOS 7

There are also many other issues where scrolling does not work correctly when having a UITableView within a UITextView. I have NO table views in my view.

I want to point out that I have subclassed UITextView and discovered that it is definitely iOS 7 making the calls to not only scroll the view upwards. Also, what's even stranger, is that when you press the back button (to delete a character from the UITextView) it sends two messages to textViewDidChangeSelection:. Here is the call stack when the issue occurs. (When the issue doesn't occur I still get the two calls to delegate textViewDidChangeSelection:... which appears to be normal):

  • UITextView delegate textViewDidChangeSelection: contains the position of the character to delete with a length of one. (ie. 1050,1)
  • UITextView delegate textViewDidChangeSelection: is called again and contains the same position but with a length of zero. (ie. 1050,0)
  • scrollViewDidScroll (up to 3 times. but generally only once)
  • Then iOS 7 calls UITextView.scrollRangeToVisible: w/ the FIRST call's range of {1050,1}. This makes no sense to me. I know this is being called internally, and NOT from any of my code, because this internal API is called _ensureselectionvisible w/o any traceback to a function I may have used to invoke scroll event.

Presumably the first two calls are simply to select the character, and then, internally, calls deleteBackwards. That makes sense. I'm flying blind after that though. I have no idea why it scrolls or why it calls scrollRangeToVisible: w/ the WRONG range.

I have been able to mitigate the issue somewhat by over-riding UITextView.scrollRangeToVisible: by returning w/o calling [super scrollRangeToVisible] when the user is editing text. I do this by over-riding some of the scrollview delegate calls and setting a flag that says scrolling can't occur. This, to me, is a huge UGLY hack and the issue still occurs in certain situations -- such as when I tap into the view when first editing and sometimes when the scroll view stops decelerating.

In short, what's happening is that a scrolling event occurs (probably due to the previous line moving up) and iOS for some reason thinks that the cursor isn't in the view. What's even more curious is that, even though the wrong range is provided, it does provide the correct position, which in turn scrolls it to the wrong location in the view. What's even more curious is that it only happens in certain conditions and only the second instance where the condition occurs. The only thing I can think of is that the height of the UITextView is not computed correctly after the text is removed and it believes the text is in a different location than it actually is.

Thanks in advance!

UPDATE:

I set the flag I spoke of earlier in scrollViewDidScroll: and it doesn't prevent the jumping in some other instances. However, there are still instances where deleting text backwards still jumps the screen. What's even more strange in the instance where the view still jumps is that I see that I am preventing [super scrollRangeToVisible] from being called! So there's something happening internally, in ADDITION, to this which is causing the view to scroll.

UPDATE:

It appears to jump between 613pts and 670pts upwards. I'm not sure what causes the difference in points. The number of lines it jumps varies as well. I would suspect that something has to be similar between the conditions. Also, when the scrollViewDidScroll: gets called I verified that the textView.selectedRange.location is the same between this call and the delegate textViewDidChangeSelection: call.

2

2 Answers

9
votes

This is a bug in recent versions of iOS. Hopefully it will be fixed soon, until then as far as I know the only option is to manually tell UITextView to scroll to the cursor position whenever the selection changes:

// in the text view's delegate
- (void)textViewDidChangeSelection:(UITextView *)textView
{
  [textView scrollRangeToVisible:textView.selectedRange];
}

That works for me, but maybe the bug is slightly different for your app. Not sure what you should do... if you can't work it out I would file a TSI with Apple. https://developer.apple.com/support/technical/submit/

0
votes

Since scrollRangeToVisible: triggers scrollViewDidScroll:, if you are watching both UITextViewDelegate and UIScrollViewDelegate, it will cause some behavior oddities. setContentOffset: will do the same too. To prevent this, you need to set the scrollView.bounds. I didn't use textViewDidChangeSelection: but textViewDidBeginEditing: instead.

- (void)textViewDidBeginEditing:(UITextView *)textView {
    CGRect caret = [textView caretRectForPosition:textView.selectedTextRange.start];
    UIEdgeInsets textInsets = textView.textContainerInset;
    CGFloat textViewHeight = textView.frame.size.height - textInsets.top - textInsets.bottom;
    // only reposition the scroll view if the caret position is out of view
    if (textViewHeight < caret.origin.y) {
        CGSize textSize = [textView.layoutManager usedRectForTextContainer:textView.textContainer].size;
        // initially place the view such that the last part of the text is visible
        CGFloat offsetY = textSize.height - textViewHeight;
        // test to see if the caret is in the middle of the text somewhere
        if ((caret.origin.y + (textViewHeight / 2)) <= textSize.height) {
            // we can, so center the caret in the middle of the view
            offsetY = caret.origin.y - (textViewHeight / 2);
        }
        // the offset indicates the point in the scrollView that will correspond to the top left of the scrollView box
        // therefore, we need to subtract the text view height to place the caret at the bottom of the scrollView, rather than the top and some empty whitespace
        [self repositionScrollView:textView newOffset:CGPointMake(caret.origin.x, offsetY)];
    }
}

/**
 This method allows for changing of the content offset for a UIScrollView without triggering the scrollViewDidScroll: delegate method.
 */
- (void)repositionScrollView:(UIScrollView *)scrollView newOffset:(CGPoint)offset {
    CGRect scrollBounds = scrollView.bounds;
    scrollBounds.origin = offset;
    scrollView.bounds = scrollBounds;
}

The contentOffset will never be greater than textSize.height - textViewHeight, so there will not be any whitespace at the bottom. If the text is somewhat long, then the code will center the caret in the middle of the UITextView; IMHO, this is the best behavior because it gives the user context of the text both above and below the caret.