首页 > 解决方案 > UITextView 放置在 UIScrollView 中时如何实现响应式自动滚动?

问题描述

UITextView本身带有默认的Scrolling Enabled行为。

当在 中创建新行(按 ENTER 键)时UITextView,将自动滚动。

在此处输入图像描述

如果你仔细观察,有顶部内边距和底部内边距,它们会沿着滚动方向移动。它是使用以下代码实现的。

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

}

现在,在单个视图控制器中,除此之外UITextView,还有其他 UI 组件,如标签、堆栈视图……在同一个控制器中

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

  1. 放在UITextView一个UIScrollView.
  2. 禁用滚动启用UITextView

这是我们的故事板的样子

在此处输入图像描述

这是最初的结果

在此处输入图像描述

我们可以观察到以下缺点。

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

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

可以从https://github.com/yccheok/uitextview-inside-uiscrollview下载演示此类缺点的示例项目

谢谢。

标签: iosswiftuiscrollviewuitextview

解决方案


这是一个众所周知的(并且长期运行的)怪癖UITextView

当输入一个换行符时,文本视图不会更新它的内容大小(以及它的框架,when .isScrollEnabled = false),直到在新行上输入另一个字符。

似乎大多数人刚刚接受了它作为苹果的默认行为。

您想要进行彻底的测试,但经过一些快速测试后,这似乎是可靠的:

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

推荐阅读