UITextView放在UIScrollView中如何实现响应式自动滚动?
How to achieve a responsive auto scrolling when UITextView is placed in UIScrollView?
UITextView
本身带有默认的 启用滚动 行为。
在 UITextView
中创建新行(按 ENTER 键)时,将自动滚动。
仔细观察,有top padding和bottom padding,会沿着滚动方向移动。它是使用以下代码实现的。
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
//
bodyTextView.textContainerInset = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
}
}
现在,在单个视图控制器中,除了 UITextView
之外,还有其他 UI 组件,例如标签、堆栈视图......在同一个控制器中
我们希望它们在滚动过程中一起移动。因此,这是我们的改进。
- 将
UITextView
放在 UIScrollView
内。
- 在
UITextView
. 中禁用 启用滚动
这是我们的故事板的样子
这是初步结果
我们可以观察到以下不足。
- 只有当我们在新行中输入第一个字符时才会自动滚动。当我们输入新行时,它不会立即发生。
- 自动滚动不再考虑底部填充。仅当“触摸”屏幕底部边缘时才会自动滚动。要理解这一点,请在没有
UIScrollView
. 的情况下
你有什么建议,我该如何克服这两个缺点?
可以从 https://github.com/yccheok/uitextview-inside-uiscrollview
下载演示此类缺点的示例项目
谢谢。
这是 UITextView
的一个众所周知的(并且很长 运行)怪癖。
输入换行符后,文本视图不会更新其内容大小(.isScrollEnabled = false
时也不会更新其框架),直到在新行上输入另一个字符。
似乎大多数人都刚刚接受它作为 Apple 的默认行为。
您想进行彻底的测试,但经过一些快速测试后这似乎是可靠的:
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)
})
}
}
}
这是一个完整的示例实现:
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)
})
}
}
}
}
UITextView
本身带有默认的 启用滚动 行为。
在 UITextView
中创建新行(按 ENTER 键)时,将自动滚动。
仔细观察,有top padding和bottom padding,会沿着滚动方向移动。它是使用以下代码实现的。
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
//
bodyTextView.textContainerInset = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
}
}
现在,在单个视图控制器中,除了 UITextView
之外,还有其他 UI 组件,例如标签、堆栈视图......在同一个控制器中
我们希望它们在滚动过程中一起移动。因此,这是我们的改进。
- 将
UITextView
放在UIScrollView
内。 - 在
UITextView
. 中禁用 启用滚动
这是我们的故事板的样子
这是初步结果
我们可以观察到以下不足。
- 只有当我们在新行中输入第一个字符时才会自动滚动。当我们输入新行时,它不会立即发生。
- 自动滚动不再考虑底部填充。仅当“触摸”屏幕底部边缘时才会自动滚动。要理解这一点,请在没有
UIScrollView
. 的情况下
你有什么建议,我该如何克服这两个缺点?
可以从 https://github.com/yccheok/uitextview-inside-uiscrollview
下载演示此类缺点的示例项目谢谢。
这是 UITextView
的一个众所周知的(并且很长 运行)怪癖。
输入换行符后,文本视图不会更新其内容大小(.isScrollEnabled = false
时也不会更新其框架),直到在新行上输入另一个字符。
似乎大多数人都刚刚接受它作为 Apple 的默认行为。
您想进行彻底的测试,但经过一些快速测试后这似乎是可靠的:
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)
})
}
}
}
这是一个完整的示例实现:
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)
})
}
}
}
}