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) }
方案原理
- 拆分文本为token:将原文本拆分为单词和对应空格的序列,便于单独处理每个单词的排版
- 预判排版位置:在添加单词的第一个字符前,计算当前行剩余宽度和单词总宽度
- 提前换行:如果当前行无法容纳整个单词,先插入换行符,确保单词从新行开始逐字显示
- 保持动画流畅:空格部分正常逐字添加,不影响原有打字动画的节奏
内容的提问来源于stack exchange,提问作者Chris
相关产品推荐
相关产品推荐

