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 组件,例如标签、堆栈视图......在同一个控制器中

我们希望它们在滚动过程中一起移动。因此,这是我们的改进。

  1. UITextView 放在 UIScrollView 内。
  2. UITextView.
  3. 中禁用 启用滚动

这是我们的故事板的样子

这是初步结果

我们可以观察到以下不足。

  1. 只有当我们在新行中输入第一个字符时才会自动滚动。当我们输入新行时,它不会立即发生。
  2. 自动滚动不再考虑底部填充。仅当“触摸”屏幕底部边缘时才会自动滚动。要理解这一点,请在没有 UIScrollView.
  3. 的情况下

你有什么建议,我该如何克服这两个缺点?

可以从 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)
                })
                
            }
        }
        
    }
    
}