chrs
chrs

Reputation: 6106

How to make a UITextView scroll while typing/editing

UPDATE This seemed to be an issue with IOS 7 only. A great workaround has been added to accepted answer.

I have created a custom control that contains a UITextView and UILabel which contains the title of the textview ie my control. My control automatically changes size to adapt the textview and the title. Before this happens I change the size of the textview to fit the text. This works optimally.

I've added functionality so the textview automatically scrolls to the last line. Or that's at least what I'm trying. It works fine as long as the last line contains anything but empty text. If the text is empty, it rolls down so you can only see about half of the cursor.

What am I doing wrong?

So you can understand it better I have made some images:

This is me typing a word and making some linebreaks. (Still not enough to make it scroll)

Before making a line break

And the I make a line break. (pressing enter) Look close at how the cursor is halved. This is the issue!

The Issue

I have made the next picture so you can see exactly what I expected.

What I Want!

Upvotes: 26

Views: 21213

Answers (15)

HotJard
HotJard

Reputation: 4818

I had the same issue, but about UITextView within UITableView, so after some investigation I didn't find any "easy" way to fix it, so based on accepted answer I'd created perfectly working solution (should work also inside UICollectionView, UIScrollView with some changes commented inside this extension).

So for easy re-using it's needed some extensions on top of UIKit:

extension UITextView {

    func scrollToCursor(animated: Bool = false, verticalInset: CGFloat = 8) {
        guard let selectedTextRange = selectedTextRange else { return }
        var cursorRect = caretRect(for: selectedTextRange.start)

        // NOTE: can't point UIScrollView, coz on iOS 10 closest view will be UITableWrapperView
        // to extend functionality for UICollectionView or plain UIScrollView it's better to search them one by one
        let scrollView = findParent(of: UITableView.self) ?? self
        cursorRect = convert(cursorRect, to: scrollView)

        if cursorRect.origin.x.isInfinite || cursorRect.origin.y.isInfinite {
            return
        }

        let bottomOverflow = cursorRect.maxY - (scrollView.contentOffset.y + scrollView.bounds.height - scrollView.contentInset.bottom - scrollView.contentInset.top)

        if bottomOverflow > 0 {
            let offset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y + bottomOverflow + verticalInset)
            scrollView.setContentOffset(offset, animated: animated)
            return
        }

        let topOverflow = scrollView.contentOffset.y - cursorRect.minY
        if topOverflow > 0 {
            let offset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y - topOverflow - verticalInset)
            scrollView.setContentOffset(offset, animated: animated)
        }
    }
}

UIView:

extension UIView {
    func findParent<Parent: UIView>(of parentType: Parent.Type) -> Parent? {
        return superview?.findNext(of: parentType)
    }

    private func findNext<Parent: UIView>(of parentType: Parent.Type) -> Parent? {
        if let res = self as? Parent {
            return res
        }

        return superview?.findNext(of: parentType)
    }
}

So on UITextViewDelegate, when text is changed, call where you need (may be inside dispatch queue main async block - I'm using ReactiveSwift callback for this):

textView.scrollToCursor()

If you wanna add moving up on cursor position change (on top part of the screen) need to call this method inside textViewDidChangeSelection delegate's method (with check on selection length of course).

Upvotes: 0

Mili Shah
Mili Shah

Reputation: 1496

In Swift 3

enter image description here

Set reference outlet & delegate of textview

class ViewController: UIViewController , UITextViewDelegate{

@IBOutlet var txtViewRef: UITextView!

In viewDidLoad set delegate & Notification for changing KeyboardFrame or Hide the keyboard

 override func viewDidLoad() {
    super.viewDidLoad()

    txtViewRef.delegate = self
    NotificationCenter.default.addObserver(self, selector: #selector(ViewController.updateTextView(notification:)), name: Notification.Name.UIKeyboardWillChangeFrame, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(ViewController.updateTextView(notification:)), name: Notification.Name.UIKeyboardWillHide, object: nil)    
}

Create Function updateTextView In which we are getting the frame of keyboard and changing the inset of content and scroll indicator and scroll the textview

func updateTextView(notification : Notification)
{
    let userInfo = notification.userInfo!
    let keyboardEndFrameScreenCoordinates = (userInfo[UIKeyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
    let keyboardEndFrame = self.view.convert(keyboardEndFrameScreenCoordinates, to: view.window)

    if notification.name == Notification.Name.UIKeyboardWillHide{
        txtViewRef.contentInset = UIEdgeInsets.zero
    }
    else
    {
        txtViewRef.contentInset = UIEdgeInsetsMake(0, 0, keyboardEndFrame.height, 0)
        txtViewRef.scrollIndicatorInsets = txtViewRef.contentInset
    }

    txtViewRef.scrollRangeToVisible(txtViewRef.selectedRange)

}

Upvotes: 1

Kishor Pahalwani
Kishor Pahalwani

Reputation: 1022

Using Swift 3 :-

let line : CGRect = textView.caretRect(for: (textView.selectedTextRange?.start)!)
    print("line = \(line)")

    let overFlow = line.origin.y + line.size.height - (textView.contentOffset.y + textView.bounds.size.height - textView.contentInset.bottom - textView.contentInset.top)

    print("\n OverFlow = \(overFlow)")

    if (0 < overFlow)
    {
        // We are at the bottom of the visible text and introduced a line feed, scroll down (iOS 7 does not do it)
        // Scroll caret to visible area

        var offSet : CGPoint = textView.contentOffset

        print("offSet = \(offSet)")

        //leave 7 pixels margin
        offSet.y += (overFlow + 7)

        //Cannot animate with setContentOffset:animated: or caret will not appear

        UIView.animate(withDuration: 0.3, animations: {
            textView.setContentOffset(offSet, animated: true)
        })
    }

Upvotes: 4

Peter Lapisu
Peter Lapisu

Reputation: 21005

On iOS10 in my autosizing UITextView the key for me was

// my method called on text change

- (void)updateLayout {

    [self invalidateIntrinsicContentSize];

    [UIView animateWithDuration:0.33 animations:^{

        [self.superview layoutIfNeeded];

        CGPoint bottomOffset = CGPointMake(0, self.contentSize.height - self.bounds.size.height);
        [self setContentOffset:bottomOffset animated:NO];

    } completion:nil];

}

The whole class

#import "AutosizeTextView.h"

@implementation AutosizeTextView

- (instancetype)initWithFrame:(CGRect)frame {

    if (self = [super initWithFrame:frame]) {
        [self setup];
    }
    return self;
}

- (void)awakeFromNib {

    [super awakeFromNib];

    [self setup];
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidChangeNotification object:self];
}

- (void)setText:(NSString *)text {
    [super setText:text];
    [self updateLayout];
}

- (CGSize)intrinsicContentSize {
    CGRect textRect = [self.layoutManager usedRectForTextContainer:self.textContainer];
    CGFloat height = textRect.size.height + self.textContainerInset.top + self.textContainerInset.bottom;
    return CGSizeMake(UIViewNoIntrinsicMetric, height);
}


////////////////////////////////////////////////////////////////////////
#pragma mark - Private
////////////////////////////////////////////////////////////////////////

- (void)setup {

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(textDidChangeNotification:) name:UITextViewTextDidChangeNotification object:self];
    self.textContainer.lineFragmentPadding = 0;
    self.textContainerInset = UIEdgeInsetsMake(4, 4, 4, 4);

}

- (void)updateLayout {

    [self invalidateIntrinsicContentSize];

    [UIView animateWithDuration:0.33 animations:^{

        [self.superview layoutIfNeeded];

        CGPoint bottomOffset = CGPointMake(0, self.contentSize.height - self.bounds.size.height);
        [self setContentOffset:bottomOffset animated:NO];

    } completion:nil];

}

////////////////////////////////////////////////////////////////////////
#pragma mark - Notification
////////////////////////////////////////////////////////////////////////

- (void)textDidChangeNotification:(NSNotification *)notification {

    [self updateLayout];

}

@end

Upvotes: 0

mikeho
mikeho

Reputation: 7010

I think the best way is to determine the actual cursor position to see if scrolling needs to occur.

- (void)textViewDidChange:(UITextView *)textView {
    // check to see if the cursor is at the end of the text
    if (textView.text.length == textView.selectedRange.location) {
        // find the caret position
        CGRect caret = [textView caretRectForPosition:textView.selectedTextRange.start];

        // determine the height of the visible text window
        UIEdgeInsets textInsets = textView.textContainerInset;
        CGFloat textViewHeight = textView.frame.size.height - textInsets.top - textInsets.bottom;
        // need to subtract the textViewHeight to correctly get the offset
        // that represents the top of the text window above the cursor
        textView.contentOffset = CGPointMake(textView.contentOffset.x, caret.origin.y - textViewHeight);
    }
}

The above code will determine if the caret is at the end of the text. If it isn't, it won't scroll. If it is (regardless of what the last character is), it will determine the correct offset to scroll to and then perform the scrolling.

Upvotes: 1

Alex Sorokoletov
Alex Sorokoletov

Reputation: 3101

Accepted answer when using Xamarin/Monotouch will look like

        textView.Changed += (object sender, EventArgs e) =>
        {

            var line = textView.GetCaretRectForPosition(textView.SelectedTextRange.start);
            var overflow = line.Top + line.Height -
                           (textView.ContentOffset.Y
                           + textView.Bounds.Size.Height
                           - textView.ContentInset.Bottom
                           - textView.ContentInset.Top);
            if (overflow > 0)
            {
                var offset = textView.ContentOffset;
                offset = new PointF(offset.X, offset.Y + overflow + 7);
                UIView.Animate(0.2f, () =>
                    {
                        textView.SetContentOffset(offset, false);
                    });
            }
        };

Upvotes: 3

Shilpi
Shilpi

Reputation: 508

Try using

   textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
   textView.autoresizingSubviews = YES;

It resolved the issue for me for iOS7.

Upvotes: 0

tyler
tyler

Reputation: 2925

Has anyone filed a bug to apple with regards to this issue? This feels like a pretty obvious bug that is very easy to reproduce. If no one responds then I will file a radar with a test project.

Upvotes: 2

davidisdk
davidisdk

Reputation: 3804

Problems with other answers:

  • when only scanning for "\n", if you type a line of text that exceeds the width of the text view, then scrolling will not occur.
  • when always setting contentOffset in textViewDidChange:, if you edit the middle of the text you do not want to scroll to the bottom.

The solution is to add this to the text view delegate:

- (void)textViewDidChange:(UITextView *)textView {
    CGRect line = [textView caretRectForPosition:
        textView.selectedTextRange.start];
    CGFloat overflow = line.origin.y + line.size.height
        - ( textView.contentOffset.y + textView.bounds.size.height
        - textView.contentInset.bottom - textView.contentInset.top );
    if ( overflow > 0 ) {
        // We are at the bottom of the visible text and introduced a line feed, scroll down (iOS 7 does not do it)
        // Scroll caret to visible area
        CGPoint offset = textView.contentOffset;
        offset.y += overflow + 7; // leave 7 pixels margin
        // Cannot animate with setContentOffset:animated: or caret will not appear
        [UIView animateWithDuration:.2 animations:^{
            [textView setContentOffset:offset];
        }];
    }
}

Upvotes: 54

nathan
nathan

Reputation: 1506

The solution in the accepted answer is unusable.

Say there are 1000 words in the textView and the final character is "\n". If you edit the first line of the textView, hasSuffix:@"\n" will return YES and the textView will immediately scroll to the bottom of the document.

Or, start with a blank textView and type one word, then press return. The text will scroll to the bottom.

============  ============   ============   ============
 Te|           Text |         Text           
                              |


                                             Text
                                             |
============  ============   ============   ============

Maybe this is a better workaround, but it's not perfect. It checks if the caret is below an maximum point, then scrolls to the maximum point if it is:

-(void)textViewDidChange:(UITextView *)textView {

    // Get caret frame
    UITextPosition *caret = [textView positionFromPosition:textView.beginningOfDocument offset:textView.selectedRange.location];
    CGRect caretFrame     = [textView caretRectForPosition:caret];

    // Get absolute y position of caret in textView
    float absCaretY       = caretFrame.origin.y - textView.contentOffset.y;

    // Set a max y for the caret (in this case the textView is resized to avoid the keyboard and an arbitrary padding is added)
    float maxCaretY       = textView.frame.size.height - 70;

    // Get how far below the maxY the caret is
    float overflow        = absCaretY - maxCaretY;

    // No need to scroll if the caret is above the maxY
    if (overflow < 0)
        return;

    // Need to add a delay for this to work
    double delayInSeconds = 0.2;
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){

        // Scroll to the maxCaretY
        CGPoint contentOffset = CGPointMake(0, textView.contentOffset.y + overflow);
        [textView setContentOffset:contentOffset animated:YES];
    });
}

Upvotes: 0

shersa1986
shersa1986

Reputation: 119

The following modification of Vik's answer worked fine for me:

if([_textView.text hasSuffix:@"\n"])
{
    if (_textView.contentSize.height - _textView.bounds.size.height > -30)
    {
        double delayInSeconds = 0.2;
        dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
        dispatch_after(popTime, dispatch_get_main_queue(), ^(void)
        {
            CGPoint bottomOffset = CGPointMake(0, _textView.contentSize.height - _textView.bounds.size.height);
            [_textView setContentOffset:bottomOffset animated:YES];
        });
    }
}

Upvotes: 2

tharris
tharris

Reputation: 2242

I have found that if you put the following in viewWillAppear, it will solve this and a few other issues that UITextView appears to have in the betas:

[self.textView.layoutManager ensureLayoutForTextContainer:self.textView.textContainer];

Upvotes: 1

Vik
Vik

Reputation: 1927

I tried to put in your textViewDidChange: a snippet like:

if([textView.text hasSuffix:@"\n"])
    [self.textView setContentOffset:CGPointMake(0,INT_MAX) animated:YES];

It's not really clean, I'm working toward finding some better stuff, but for now it works :D

UPDATE: Since this is a bug that only happens on iOS 7 (Beta 5, for now), you can do a workaround with this code:

if([textView.text hasSuffix:@"\n"]) { 
    double delayInSeconds = 0.2; 
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); 
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ 
        CGPoint bottomOffset = CGPointMake(0, self.textView.contentSize.height - self.textView.bounds.size.height); 
        [self.textView setContentOffset:bottomOffset animated:YES]; 
    }); 
}

Then, on iOS 6 you can choose either to set the delay to 0.0 or to use just the content of the block.

Upvotes: 8

hgwhittle
hgwhittle

Reputation: 9426

I used the following code in the textViewDidChange: method and it seemed to work well.

- (void)textViewDidChange:(UITextView *)textView {
    CGPoint bottomOffset = CGPointMake(0, self.theTextView.contentSize.height - self.theTextView.bounds.size.height);
    [self.theTextView setContentOffset:bottomOffset animated:YES];
}

This seems to scroll the UITextView slightly further so that your cursor isn't cut off.

Upvotes: 4

JustAnotherCoder
JustAnotherCoder

Reputation: 2575

This is what I used on my current project to resize a UITextView:

- (void)textViewDidChange:(UITextView *)textView {
    CGRect frame = textView.frame;
    frame.size.height = textView.contentSize.height;
    textView.frame = frame;    
}

It works very well for me. If you want to create a little "border" between the cursor and the actual text box, you can always add a few pixels to the height. Like so:

    frame.size.height = textView.contentSize.height+14;

Upvotes: 0

Related Questions