2

UITextView itself comes with default Scrolling Enabled behavior.

When a new line is created (ENTER pressed) in UITextView, scrolling will happen automatically.

enter image description here

If you notice carefully, there is top padding and bottom padding, which will move along the scroll direction. It is achieved using the following code.

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var bodyTextView: UITextView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        // We want to have "clipToPadding" for top and bottom.
        // To understand what is "clipToPadding", please refer to
        // https://stackoverflow.com/a/46710968/72437
        bodyTextView.textContainerInset = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
    }

}

Now, within a single view controller, besides UITextView, there are other UI components like labels, stack views, ... within same controller

We want them to move along together during scrolling. Hence, here's our improvements.

  1. Place UITextView inside a UIScrollView.
  2. Disable Scrolling Enabled in UITextView.

Here's how our storyboard looks like

enter image description here

Here's the initial outcome

enter image description here

We can observe the following shortcomings.

  1. Auto scrolling only happen, when we type the first character in new line. It doesn't happen immediately when we ENTER a new line.
  2. Auto scrolling no longer take consideration into bottom padding. Auto scrolling only happen when bottom edge of screen is "touched". To understand this, please with case without UIScrollView.

Do you have any suggestions, how I can overcome these 2 shortcomings?

A sample project to demonstrate such shortcomings can be downloaded from https://github.com/yccheok/uitextview-inside-uiscrollview

Thanks.

Cheok Yan Cheng
  • 47,586
  • 132
  • 466
  • 875
  • you can add bottom padding with scroll view rather than in text view, make bottom padding of text view 0 and add bottom space for textview using bottom constraint with scrollview. – Azhar Tahir Aug 10 '21 at 05:52

1 Answers1

1

This is a well-known (and long running) quirk with UITextView.

When a newline is entered, the text view does not update its content size (and thus its frame, when .isScrollEnabled = false) until another character is entered on the new line.

It seems that most people have just accepted it as Apple's default behavior.

You'd want to do thorough testing, but after some quick testing this appears to be reliable:

func textViewDidChange(_ textView: UITextView) {
    
    // if the cursor is at the end of the text, and the last char is a newline
    if let selectedRange = textView.selectedTextRange,
       let txt = textView.text,
       !txt.isEmpty,
       txt.last == "\n" {
        let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
        if cursorPosition == txt.count {

            // UITextView has a quirk when last char is a newline...
            //  its size is not updated until another char is entered
            //  so, this will force the textView to scroll up
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
                self.textView.sizeToFit()
                self.textView.layoutIfNeeded()
                // might prefer setting animated: true 
                self.scrollView.scrollRectToVisible(self.textView.frame, animated: false)
            })
            
        }
    }
    
}

Here's a complete example implementation:

class ViewController: UIViewController, UITextViewDelegate {
    
    let textView: UITextView = {
        let v = UITextView()
        v.textColor = .black
        v.backgroundColor = .green
        v.font = .systemFont(ofSize: 17.0)
        v.isScrollEnabled = false
        return v
    }()
    
    let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.backgroundColor = .red
        v.alwaysBounceVertical = true
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // create a vertical stack view
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 8

        // add a few labels to the stack view
        let strs: [String] = [
            "Three Labels in a Vertical Stack View",
            "Just so we can see that there are UI elements in the scroll view in addition to the text view.",
            "Stack View is, of course,\nusing auto-layout constraints."
        ]
        strs.forEach { str in
            let v = UILabel()
            v.backgroundColor = .yellow
            v.text = str
            v.textAlignment = .center
            v.numberOfLines = 0
            stackView.addArrangedSubview(v)
        }
        
        // we're setting constraints
        [scrollView, stackView, textView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }
        
        // add views to hierarchy
        scrollView.addSubview(stackView)
        scrollView.addSubview(textView)
        view.addSubview(scrollView)
        
        // respect safe area
        let g = view.safeAreaLayoutGuide
        
        // references to scroll view's Content and Frame Layout Guides
        let cg = scrollView.contentLayoutGuide
        let fg = scrollView.frameLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // constrain scrollView to view (safe area)
            scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            
            // constrain stackView Top / Leading  / Trailing to Content Layout Guide
            stackView.topAnchor.constraint(equalTo: cg.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
            
            // stackView width equals scrollView Frame Layout Guide width
            stackView.widthAnchor.constraint(equalTo: fg.widthAnchor),
            
            // constrain textView Top to stackView Bottom + 12
            //  Leading / Trailing to Content Layout Guide
            textView.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12.0),
            textView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
            textView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
            
            // textView width equals scrollView Frame Layout Guide width
            textView.widthAnchor.constraint(equalTo: fg.widthAnchor),

            // constrain textView Bottom to Content Layout Guide
            textView.bottomAnchor.constraint(equalTo: cg.bottomAnchor),
            
        ])
        
        // some initial text
        textView.text = "This is the textView"
        
        // set the delegate
        textView.delegate = self
        
        // if we want
        //textView.textContainerInset = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
        
        // a right-bar-button to end editing
        let btn = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done(_:)))
        navigationItem.setRightBarButton(btn, animated: true)
        
        // keyboard notifications
        let notificationCenter = NotificationCenter.default
        notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
        notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)

    }

    @objc func done(_ b: Any?) -> Void {
        view.endEditing(true)
    }
    
    @objc func adjustForKeyboard(notification: Notification) {
        guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
        
        let keyboardScreenEndFrame = keyboardValue.cgRectValue
        let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
        
        if notification.name == UIResponder.keyboardWillHideNotification {
            scrollView.contentInset = .zero
        } else {
            scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
        }
        
        scrollView.scrollIndicatorInsets = scrollView.contentInset
    }

    func textViewDidChange(_ textView: UITextView) {
        
        // if the cursor is at the end of the text, and the last char is a newline
        if let selectedRange = textView.selectedTextRange,
           let txt = textView.text,
           !txt.isEmpty,
           txt.last == "\n" {
            let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
            if cursorPosition == txt.count {

                // UITextView has a quirk when last char is a newline...
                //  its size is not updated until another char is entered
                //  so, this will force the textView to scroll up
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
                    self.textView.sizeToFit()
                    self.textView.layoutIfNeeded()
                    // might prefer setting animated: true 
                    self.scrollView.scrollRectToVisible(self.textView.frame, animated: false)
                })
                
            }
        }
        
    }
    
}
DonMag
  • 69,424
  • 5
  • 50
  • 86