iOS 7.1 UITextView still not scrolling to cursor/caret after new line

14,965

Solution 1

Improved solution's code for UITextView descendant class:

#define SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] != NSOrderedAscending)
#define is_iOS7 SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(@"7.0")
#define is_iOS8 SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(@"8.0")

@implementation MyTextView {
    BOOL settingText;
}

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleTextViewDidChangeNotification:) name:UITextViewTextDidChangeNotification object:self];
    }
    return self;
}

- (void)scrollToCaretInTextView:(UITextView *)textView animated:(BOOL)animated {
    CGRect rect = [textView caretRectForPosition:textView.selectedTextRange.end];
    rect.size.height += textView.textContainerInset.bottom;
    [textView scrollRectToVisible:rect animated:animated];
}

- (void)handleTextViewDidChangeNotification:(NSNotification *)notification {
    if (notification.object == self && is_iOS7 && !is_iOS8 && !settingText) {
        UITextView *textView = self;
        if ([textView.text hasSuffix:@"\n"]) {
            [CATransaction setCompletionBlock:^{
                [self scrollToCaretInTextView:textView animated:NO];
            }];
        } else {
            [self scrollToCaretInTextView:textView animated:NO];
        }
    }
}

- (void)setText:(NSString *)text {
    settingText = YES;
    [super setText:text];
    settingText = NO;
}

Note it doesn't work when Down key is pressed on Bluetooth keyboard.

Solution 2

A robust solution should hold up in the following situations:

(1.) a text view displaying an attributed string

(2.) a new line created by tapping the return key on the keyboard

(3.) a new line created by typing text that overflows to the next line

(4.) copy and paste text

(5.) a new line created by tapping the return key for the first time (see the 3 steps in the OP)

(6.) device rotation

(7.) some case I can't think of that you will...

To satisfy these requirements in iOS 7.1, it seems as though it's still necessary to manually scroll to the caret.

It's common to see solutions that manually scroll to the caret when the text view delegate method textViewDidChange: is called. However, I found that this technique did not satisfy situation #5 above. Even a call to layoutIfNeeded before scrolling to the caret didn't help. Instead, I had to scroll to the caret inside a CATransaction completion block:

// this seems to satisfy all of the requirements listed above–if you are targeting iOS 7.1
- (void)textViewDidChange:(UITextView *)textView
{
    if ([textView.text hasSuffix:@"\n"]) {

        [CATransaction setCompletionBlock:^{
            [self scrollToCaretInTextView:textView animated:NO];
        }];

    } else {
        [self scrollToCaretInTextView:textView animated:NO];
    }
}

Why does this work? I have no idea. You'll have to ask an Apple engineer.

For completeness, here's all of the code related to my solution:

#import "ViewController.h"

@interface ViewController () <UITextViewDelegate>

@property (weak, nonatomic) IBOutlet UITextView *textView; // full-screen

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    NSString *string = @"All work and no play makes Jack a dull boy.\n\nAll work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy.";

    NSAttributedString *attrString = [[NSAttributedString alloc] initWithString:string attributes:@{NSFontAttributeName: [UIFont fontWithName:@"Verdana" size:30.0]}];

    self.textView.attributedText = attrString;

    self.textView.delegate = self;
    self.textView.backgroundColor = [UIColor yellowColor];
    [self.textView becomeFirstResponder];

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardIsUp:) name:UIKeyboardDidShowNotification object:nil];
}

// helper method
- (void)scrollToCaretInTextView:(UITextView *)textView animated:(BOOL)animated
{
    CGRect rect = [textView caretRectForPosition:textView.selectedTextRange.end];
    rect.size.height += textView.textContainerInset.bottom;
    [textView scrollRectToVisible:rect animated:animated];
}

- (void)keyboardIsUp:(NSNotification *)notification
{
    NSDictionary *info = [notification userInfo];
    CGRect keyboardRect = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
    keyboardRect = [self.view convertRect:keyboardRect fromView:nil];

    UIEdgeInsets inset = self.textView.contentInset;
    inset.bottom = keyboardRect.size.height;
    self.textView.contentInset = inset;
    self.textView.scrollIndicatorInsets = inset;

    [self scrollToCaretInTextView:self.textView animated:YES];
}

- (void)textViewDidChange:(UITextView *)textView
{
    if ([textView.text hasSuffix:@"\n"]) {

        [CATransaction setCompletionBlock:^{
            [self scrollToCaretInTextView:textView animated:NO];
        }];

    } else {
        [self scrollToCaretInTextView:textView animated:NO];
    }
}

@end

If you find a situation where this doesn't work, please let me know.

Solution 3

I solved it by getting the actual position of the caret and adjusting to it, here's my method:

- (void) alignTextView:(UITextView *)textView withAnimation:(BOOL)shouldAnimate {

    // where the blinky caret is
    CGRect caretRect = [textView caretRectForPosition:textView.selectedTextRange.start];
    CGFloat offscreen = caretRect.origin.y + caretRect.size.height - (textView.contentOffset.y + textView.bounds.size.height - textView.contentInset.bottom - textView.contentInset.top);

    CGPoint offsetP = textView.contentOffset;
    offsetP.y += offscreen + 3; // 3 px -- margin puts caret 3 px above bottom

    if (offsetP.y >= 0) {
        if (shouldAnimate) {
            [UIView animateWithDuration:0.2 animations:^{
                [textView setContentOffset:offsetP];
            }];
        }
        else {
            [textView setContentOffset:offsetP];
        }
    }
}

If you only need to orient after the user presses return / enter, try:

- (void) textViewDidChange:(UITextView *)textView {
    if ([textView.text hasSuffix:@"\n"]) {
        [self alignTextView:textView withAnimation:NO];
    }
}

Let me know if it works for you!

Share:
14,965

Related videos on Youtube

bilobatum
Author by

bilobatum

I only do iOS development.

Updated on June 04, 2022

Comments

  • bilobatum
    bilobatum almost 2 years

    Since iOS 7, a UITextView does not scroll automatically to the cursor as the user types text that flows to a new line. This issue is well documented on SO and elsewhere. For me, the issue is still present in iOS 7.1. What am I doing wrong?

    enter image description here

    I installed Xcode 5.1 and targeted iOS 7.1. I'm using Auto Layout.

    Here's how I position the text view's content above the keyboard:

    - (void)keyboardUp:(NSNotification *)notification
    {
        NSDictionary *info = [notification userInfo];
        CGRect keyboardRect = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
        keyboardRect = [self.view convertRect:keyboardRect fromView:nil];
    
        UIEdgeInsets contentInset = self.textView.contentInset;
        contentInset.bottom = keyboardRect.size.height;
        self.textView.contentInset = contentInset;
    }
    

    What I have tried: I have tried many of the solutions posted to SO on this issue as it pertains to iOS 7. All of the solutions that I have tried do not seem to hold up well for text views displaying an attributed string. In the following three steps, I outline how the most up-voted answer on SO (https://stackoverflow.com/a/19277383/1239263) responds to the user tapping the return key for the first time.

    (1.) The text view became the first responder in viewDidLoad. Scroll to the bottom of the text view where the cursor is located.

    enter image description here

    (2.) Before typing a single character, tap the return key on the keyboard. The caret disappears out of sight.

    enter image description here

    (3.) Tapping the return key again, however, seems to normalize the situation. (Note: deleting the latter new line, however, makes the caret disappear once again).

    enter image description here

    • Oliver Atkinson
      Oliver Atkinson about 10 years
      @bilobatum Check out peter steinburgers post, this is exactly the problem he solves with a great explanation - petersteinberger.com/blog/2014/fixing-uitextview-on-ios-7
    • Dmitry
      Dmitry almost 10 years
      The issue is FIXED on iOS 8.
    • bilobatum
      bilobatum
      It should also be noted that this scroll issue is still present in Apple's Calendar app after upgrading to iOS 7.1. Create a new event, scroll down to the "Notes" section, hit the return key repeatedly until the cursor disappears.
  • nielsbot
    nielsbot about 10 years
    @bilobatum sounds like your solution is only a partial solution. the text view should always show the cursor despite the OS bug. In addition, using contentInset is easier that changing the view frame IMO.
  • Logan
    Logan about 10 years
    What if you only use the caret if user presses enter, see edits.
  • bilobatum
    bilobatum about 10 years
    @Logan Doesn't work because the new line character is not yet set down in the text view when textView:shouldChangeTextInRange:replacementText: is called.
  • bilobatum
    bilobatum about 10 years
    @Logan Ok, that works. I'll give you a +1, but I'm going to defer selecting an answer for a bit. With iOS 7.1, I was really hoping to avoid the manual scrolling hacks to do something that was available for free in iOS 6.
  • Logan
    Logan about 10 years
    Hopefully something arises!
  • bilobatum
    bilobatum about 10 years
    Have you tried this solution with a text view that displays an attributed string? I couldn't get it to work.
  • bilobatum
    bilobatum about 10 years
    @Logan I found a problem with your solution when the text view displays an attributed string. The first tap on the return key hides the caret behind the keyboard.
  • Logan
    Logan about 10 years
    That's very strange, I wonder what about the attributed string would cause that. Is it going completely off screen?
  • bilobatum
    bilobatum about 10 years
    @Logan Yes, it disappears completely.
  • Dmitry
    Dmitry almost 10 years
    Your solution doesn't work with Down key on Bluetooth keyboard.
  • Dmitry
    Dmitry almost 10 years
    It breaks setText method - text always will be scrolled to the end if the new text has @"\n" suffix.
  • bilobatum
    bilobatum almost 10 years
    I like it (although your initializer is incomplete). Presumably setAttributedText: will also have to be overridden. I'm glad iOS 8 fixes this bug.
  • Dmitry
    Dmitry almost 10 years
    Initializer was fixed.