26

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!

chrs
  • 5,906
  • 10
  • 43
  • 74

15 Answers15

54

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];
        }];
    }
}
davidisdk
  • 3,358
  • 23
  • 12
  • The solves the problem with the last line, but causes strange scrolling when trying to edit near the top of a long document. – Vebjorn Ljosa Mar 15 '14 at 13:15
  • There is a problem with this solution. 1. Add enough rows of text to fill text view. 2. Leave cursor on blank line on last row. 3. Scroll to top via touch. 4. Type a character. Bug: The content offset is too large. Expected: To be sexy as in other cases. – Travis Mar 26 '14 at 21:30
8

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.

Vik
  • 1,897
  • 12
  • 18
  • It works, but it is as you mention a little unclean. But Thanks for the effort. First "solution" at this time – chrs Aug 09 '13 at 09:43
  • Another thing you can do, maybe "cleaner" is: `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]; }); }` – Vik Aug 09 '13 at 09:55
  • 1
    the problem is that in `textViewDidChange:` the contentSize is not yet updated to the real one, but to the one of the previous edit. It can also be a temporary bug with iOS 7 – Vik Aug 09 '13 at 09:55
  • The last comment is also what I think for now, since everybody else thinks I am completely lost hehe. I am trying to download the 6.1 simulator and see if it runs on that smoothly. – chrs Aug 09 '13 at 10:00
  • I've just tried it on iOS 6.1 and it works ok, even setting the delay to 0.0 – Vik Aug 09 '13 at 10:07
  • The "normal" approach or the hack? – chrs Aug 09 '13 at 10:12
  • The "normal" approach and the "hack" – Vik Aug 09 '13 at 10:14
  • OK. Can you update your answer so it is a bug, and you have found a workaround? Then I'll give you the bounty ;) – chrs Aug 09 '13 at 10:16
4

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.

hgwhittle
  • 9,316
  • 6
  • 48
  • 60
  • I don't know why. But it didn't work for me :( Updated my Post – chrs Aug 09 '13 at 08:58
  • For Swift 4 func textViewDidChange(_ textView: UITextView) { switch textView { case reasonsText: let bottomOffset = CGPoint(x: 0, y: textView.contentSize.height - textView.bounds.size.height) reasonsText.setContentOffset(bottomOffset, animated: false) default: break } } – clopex Sep 26 '19 at 11:04
4

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)
        })
    }
Kishor Pahalwani
  • 1,010
  • 1
  • 23
  • 53
3

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);
                    });
            }
        };
Community
  • 1
  • 1
Alex Sorokoletov
  • 3,102
  • 2
  • 30
  • 52
2

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];
        });
    }
}
shersa1986
  • 119
  • 1
  • 5
2

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.

tyler
  • 2,865
  • 1
  • 23
  • 28
1

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];

tharris
  • 2,192
  • 2
  • 13
  • 14
  • 1
    This didn't seem to do anything for me. – tyler Oct 14 '13 at 21:31
  • This made the UITextView hidden behind my keyboard and sort of defeated the whole purpose. I can imagine this working alongside something to compensate for hidden keyboard OR if the UITextView is at the top of the screen and therefore not likely to be hidden. – LpLrich Jan 14 '14 at 14:21
1

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.

mikeho
  • 6,790
  • 3
  • 34
  • 46
1

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)

}
Mili Shah
  • 1,456
  • 13
  • 21
0

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;
JustAnotherCoder
  • 2,565
  • 17
  • 38
0

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];
    });
}
nathan
  • 1,456
  • 1
  • 15
  • 32
  • This solution causes the text view to scroll a second time past the end after pasting a large block of text. @davidisdk's solution does not appear to have this issue. – titaniumdecoy Nov 07 '13 at 22:57
0

Try using

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

It resolved the issue for me for iOS7.

Shilpi
  • 498
  • 1
  • 6
  • 13
0

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
Peter Lapisu
  • 19,915
  • 16
  • 123
  • 179
0

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).

HotJard
  • 4,598
  • 2
  • 36
  • 36