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

UITextView逐字打字动画换行卡顿问题求解

问题解决:UITextView逐字打字动画跨行单词排版优化

问题描述

固定宽高的UITextView使用Timer实现逐字打字动画时,遇到跨行单词会出现视觉卡顿:单词的首字符会先显示在当前行末尾,直到输入到足够多的字符导致整词无法容纳时,才会整体跳转到下一行。

当前实现代码

func type(text: String) {

    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .center
    paragraphStyle.lineBreakMode = .byWordWrapping
    paragraphStyle.paragraphSpacing = 10

    let bodyAttributes = [
        NSAttributedString.Key.foregroundColor: UIColor.black,
        NSAttributedString.Key.paragraphStyle: paragraphStyle,
        NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17)
    ]

    let characters = Array(text)
    var characterIndex = 0

    let attributedString = NSMutableAttributedString()

    Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] timer in
        guard let self = self else { return }

        while characters[characterIndex] == " " {
            attributedString.append(NSAttributedString(string: " ", attributes: bodyAttributes))
            textView.attributedText = attributedString

            characterIndex += 1

            if characterIndex == characters.count {
                timer.invalidate()
                return
            }
        }

        let char = String(characters[characterIndex])
       
            attributedString.append(NSAttributedString(string: char, attributes: bodyAttributes))
        
        textView.attributedText = attributedString

        characterIndex += 1

        if characterIndex == characters.count {
            timer.invalidate()
        }
    }
}

问题原因

byWordWrapping的换行逻辑是仅当整个单词无法放入当前行时才触发换行,逐字添加字符时,单个字符可以放入当前行末尾,因此会先显示在当前行,直到单词长度超过行宽才整体换行,导致视觉卡顿。

解决方案

通过预判单词排版位置,在添加跨行单词的首字符前先插入换行,确保整个单词从新行开始显示。具体实现如下:

修改后的代码

func type(text: String) {
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .center
    paragraphStyle.lineBreakMode = .byWordWrapping
    paragraphStyle.paragraphSpacing = 10

    let bodyAttributes: [NSAttributedString.Key: Any] = [
        .foregroundColor: UIColor.black,
        .paragraphStyle: paragraphStyle,
        .font: UIFont.systemFont(ofSize: 17)
    ]

    // 拆分文本为「单词+对应空格」的token数组
    var tokens: [String] = []
    text.enumerateSubstrings(in: text.startIndex..<text.endIndex, options: .byWords) { substring, substringRange, enclosingRange, _ in
        if let substring = substring {
            tokens.append(substring)
            // 保留单词后的空格部分
            let spaceRange = substringRange.upperBound..<enclosingRange.upperBound
            tokens.append(String(text[spaceRange]))
        }
    }
    // 处理文本末尾的剩余非单词内容
    if let lastToken = tokens.last, !lastToken.isEmpty, 
       let lastRange = text.range(of: lastToken, options: .backwards), 
       lastRange.upperBound < text.endIndex {
        tokens.append(String(text[lastRange.upperBound..<text.endIndex]))
    }

    var tokenIndex = 0
    var charIndexInToken = 0
    let attributedString = NSMutableAttributedString()

    Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] timer in
        guard let self = self else {
            timer.invalidate()
            return
        }

        guard tokenIndex < tokens.count else {
            timer.invalidate()
            return
        }

        let currentToken = tokens[tokenIndex]
        if currentToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            // 逐字添加空格token
            let char = String(currentToken[charIndexInToken])
            attributedString.append(NSAttributedString(string: char, attributes: bodyAttributes))
            self.textView.attributedText = attributedString
            charIndexInToken += 1

            if charIndexInToken == currentToken.count {
                tokenIndex += 1
                charIndexInToken = 0
            }
            return
        }

        // 计算当前行剩余宽度与单词总宽度
        let wordWidth = self.widthOfString(currentToken, with: bodyAttributes)
        let remainingWidth = self.remainingWidthInLastLine()

        // 当前行放不下整个单词时,先插入换行
        if remainingWidth < wordWidth && charIndexInToken == 0 {
            attributedString.append(NSAttributedString(string: "\n", attributes: bodyAttributes))
            self.textView.attributedText = attributedString
        }

        // 逐字添加单词字符
        let char = String(currentToken[charIndexInToken])
        attributedString.append(NSAttributedString(string: char, attributes: bodyAttributes))
        self.textView.attributedText = attributedString

        charIndexInToken += 1
        if charIndexInToken == currentToken.count {
            tokenIndex += 1
            charIndexInToken = 0
        }
    }
}

// 计算字符串在当前属性下的宽度
private func widthOfString(_ string: String, with attributes: [NSAttributedString.Key: Any]) -> CGFloat {
    let size = string.size(withAttributes: attributes)
    return size.width
}

// 计算UITextView最后一行的剩余可用宽度
private func remainingWidthInLastLine() -> CGFloat {
    guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else {
        return textContainer?.size.width ?? 0
    }
    let lastGlyphIndex = layoutManager.numberOfGlyphs - 1
    if lastGlyphIndex < 0 {
        // 文本为空时返回容器可用宽度
        return textContainer.size.width - textContainer.lineFragmentPadding * 2
    }
    let lastLineRect = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphIndex, effectiveRange: nil)
    let lastGlyphPosition = layoutManager.location(forGlyphAt: lastGlyphIndex)
    let usedWidth = lastGlyphPosition.x - lastLineRect.origin.x
    let remainingWidth = lastLineRect.width - usedWidth - textContainer.lineFragmentPadding
    return max(remainingWidth, 0)
}

方案原理

  1. 拆分文本为token:将原文本拆分为单词和对应空格的序列,便于单独处理每个单词的排版
  2. 预判排版位置:在添加单词的第一个字符前,计算当前行剩余宽度和单词总宽度
  3. 提前换行:如果当前行无法容纳整个单词,先插入换行符,确保单词从新行开始逐字显示
  4. 保持动画流畅:空格部分正常逐字添加,不影响原有打字动画的节奏

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.28 19:07:18