使用Text Kit 2时UITextView滚动卡顿无法到底部的问题求助
问题描述
我将usingTextLayoutManager属性设为true,在UITextView中使用Text Kit 2。尝试为NSAttributedString添加左右缩进时,长文本场景下UITextView出现滚动卡顿,且无法滚动到底部。该问题在Text Kit 1下不存在,但我需要使用Text Kit 2的新特性,推测问题与UITextView的contentSize计算有关。
相关代码
MyTextView
import UIKit class MyTextView: UITextView { convenience init(usingTextLayoutManager: Bool) { let textContainer = NSTextContainer(size: .zero) if usingTextLayoutManager { let layoutManager = NSTextLayoutManager() layoutManager.textContainer = textContainer self.init(frame: .zero, textContainer: textContainer) self.layoutManager = layoutManager } else { self.init(frame: .zero, textContainer: textContainer) } } override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) setUpTextView() setUpAttributes() setUpInputAccessoryView() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setUpTextView() { alwaysBounceVertical = true keyboardDismissMode = .interactiveWithAccessory backgroundColor = .white } func setUpAttributes() { let font = UIFont.systemFont(ofSize: 24, weight: .semibold) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = font.pointSize * 0.15 typingAttributes = [ .font: font, .paragraphStyle: paragraphStyle ] let string = """ When you try your best, but you don't succeed When you get what you want, but not what you need When you feel so tired, but you can't sleep Stuck in reverse And the tears come streaming down your face When you lose something you can't replace When you love someone, but it goes to waste Could it be worse? Lights will guide you home And ignite your bones And I will try to fix you And high up above, or down below When you're too in love to let it go But if you never try, you'll never know Just what you're worth Lights will guide you home And ignite your bones And I will try to fix you Tears stream down your face When you lose something you cannot replace Tears stream down your face, and I Tears stream down your face I promise you I will learn from my mistakes Tears stream down your face, and I Lights will guide you home And ignite your bones And I will try to fix you When you try your best, but you don't succeed When you get what you want, but not what you need When you feel so tired, but you can't sleep Stuck in reverse And the tears come streaming down your face When you lose something you can't replace When you love someone, but it goes to waste Could it be worse? Lights will guide you home And ignite your bones And I will try to fix you And high up above, or down below When you're too in love to let it go But if you never try, you'll never know Just what you're worth Lights will guide you home And ignite your bones And I will try to fix you Tears stream down your face When you lose something you cannot replace Tears stream down your face, and I Tears stream down your face I promise you I will learn from my mistakes Tears stream down your face, and I Lights will guide you home And ignite your bones And I will try to fix you """ let attributedString = NSMutableAttributedString( string: string, attributes: [ .font: font, .paragraphStyle: paragraphStyle ] ) textStorage.setAttributedString(attributedString) } func indentLeft() { let fullRange = NSRange(location: 0, length: textStorage.length) let mutableText = NSMutableAttributedString(attributedString: textStorage) mutableText.enumerateAttribute(.paragraphStyle, in: fullRange, options: []) { attribute, range, _ in let paragraphStyle = (attribute as? NSParagraphStyle) ?? NSMutableParagraphStyle() let newStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle if newStyle.firstLineHeadIndent > 0 { newStyle.firstLineHeadIndent -= 40 newStyle.headIndent -= 40 } else { newStyle.firstLineHeadIndent = 0 newStyle.headIndent = 0 } mutableText.addAttribute(.paragraphStyle, value: newStyle, range: range) } textStorage.setAttributedString(mutableText) textLayoutManager?.invalidateLayout(for: .all) setNeedsLayout() } func indentRight() { let fullRange = NSRange(location: 0, length: textStorage.length) let mutableText = NSMutableAttributedString(attributedString: textStorage) mutableText.enumerateAttribute(.paragraphStyle, in: fullRange, options: []) { attribute, range, _ in let paragraphStyle = (attribute as? NSParagraphStyle) ?? NSMutableParagraphStyle() let newStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle if newStyle.firstLineHeadIndent < 160 { newStyle.firstLineHeadIndent += 40 newStyle.headIndent += 40 } else { newStyle.firstLineHeadIndent = 0 newStyle.headIndent = 0 } mutableText.addAttribute(.paragraphStyle, value: newStyle, range: range) } textStorage.setAttributedString(mutableText) textLayoutManager?.invalidateLayout(for: .all) setNeedsLayout() } func setUpInputAccessoryView() { let view = AccessoryView(frame: .init(origin: .zero, size: .init(width: .zero, height: 50))) view.textView = self inputAccessoryView = view } }
ViewController
class ViewController: UIViewController { var textView: MyTextView! override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white setUpTextView() } func setUpTextView() { textView = MyTextView(usingTextLayoutManager: true) textView.layer.borderColor = UIColor.black.cgColor textView.layer.borderWidth = 2 view.addSubview(textView) setUpConstraints() let _ = textView.becomeFirstResponder() } func setUpConstraints() { textView.translatesAutoresizingMaskIntoConstraints = false [ textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) ].forEach { $0.isActive = true } } }
AccessoryView
class AccessoryView: UIView { weak var textView: MyTextView? let indentLeft: UIButton = { let button = UIButton(type: .system) button.setImage(UIImage(systemName: "lessthan"), for: .normal) return button }() let indentRight: UIButton = { let button = UIButton(type: .system) button.setImage(UIImage(systemName: "greaterthan"), for: .normal) return button }() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.borderColor = UIColor.black.cgColor layer.borderWidth = 2 addSubview(indentLeft) indentLeft.translatesAutoresizingMaskIntoConstraints = false indentLeft.topAnchor.constraint(equalTo: topAnchor).isActive = true indentLeft.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true indentLeft.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true indentLeft.widthAnchor.constraint(equalToConstant: 55).isActive = true indentLeft.addTarget(self, action: #selector(handleIndentLeft), for: .touchUpInside) addSubview(indentRight) indentRight.translatesAutoresizingMaskIntoConstraints = false indentRight.topAnchor.constraint(equalTo: topAnchor).isActive = true indentRight.leadingAnchor.constraint(equalTo: indentLeft.trailingAnchor).isActive = true indentRight.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true indentRight.widthAnchor.constraint(equalToConstant: 55).isActive = true indentRight.addTarget(self, action: #selector(handleIndentRight), for: .touchUpInside) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc func handleIndentLeft(button: UIButton) { guard let textView = textView else { return } textView.indentLeft() } @objc func handleIndentRight(button: UIButton) { guard let textView = textView else { return } textView.indentRight() } }
问题复现步骤
- 在真机上运行代码
- 选中全部文本
- 点击右缩进按钮2-3次
- 滚动到顶部后再尝试滚动到底部
原因分析
- Text Kit 2的
NSTextLayoutManager在批量修改段落样式时,contentSize的计算存在延迟或不同步问题,导致scrollView无法正确识别文本实际高度,进而出现滚动限制。 - 原代码中逐段修改
textStorage的属性,会触发多次局部布局更新,长文本场景下重复的布局计算会引发卡顿。
解决方案
- 批量更新文本属性:先将整个文本转为可变属性字符串,完成所有段落样式修改后,一次性设置回
textStorage,减少布局触发次数。 - 强制同步布局:修改完成后,调用
textLayoutManager?.invalidateLayout(for: .all)强制刷新整个布局,再通过setNeedsLayout确保contentSize重新计算。 - 修正初始化逻辑:确保
usingTextLayoutManager参数真正生效,正确初始化Text Kit 2的布局管理器。
内容的提问来源于stack exchange,提问作者FE_Tech
相关产品推荐
相关产品推荐

