You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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次
  • 滚动到顶部后再尝试滚动到底部
原因分析
  1. Text Kit 2的NSTextLayoutManager在批量修改段落样式时,contentSize的计算存在延迟或不同步问题,导致scrollView无法正确识别文本实际高度,进而出现滚动限制。
  2. 原代码中逐段修改textStorage的属性,会触发多次局部布局更新,长文本场景下重复的布局计算会引发卡顿。
解决方案
  1. 批量更新文本属性:先将整个文本转为可变属性字符串,完成所有段落样式修改后,一次性设置回textStorage,减少布局触发次数。
  2. 强制同步布局:修改完成后,调用textLayoutManager?.invalidateLayout(for: .all)强制刷新整个布局,再通过setNeedsLayout确保contentSize重新计算。
  3. 修正初始化逻辑:确保usingTextLayoutManager参数真正生效,正确初始化Text Kit 2的布局管理器。

内容的提问来源于stack exchange,提问作者FE_Tech

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 06:52:02