UITextView itself comes with default Scrolling Enabled behavior.
When a new line is created (ENTER pressed) in UITextView, scrolling will happen automatically.

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.
UITextView inside a UIScrollView.UITextView.Here's how our storyboard looks like

Here's the initial outcome

We can observe the following shortcomings.
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.
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)
})
}
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With