如何实现类似Apple Keynote的逐字/逐词文本动画(Swift macOS开发)
实现macOS SwiftUI文本逐字/逐词动画的正确方案
核心问题分析
你用HStack拆分字符/单词的问题在于,系统文本的排版(字距、连字、tracking等)是动态计算的,HStack的固定spacing无法还原这些原生细节,导致字距偏差。要解决这个问题,必须基于系统原生排版的位置来构建动画元素。
推荐实现方案
下面给出两种实用方案,分别适合不同场景:
方案1:CoreText+ZStack(简单易实现,适合短文本)
通过CoreText获取每个字符/单词的原生排版位置,再用ZStack叠加单个文本元素,保证排版准确的同时实现动画。
1. 封装CoreText位置计算工具
import CoreText import SwiftUI struct TextLayoutData { // 单字符信息 struct GlyphInfo { let character: String let frame: CGRect } // 单词信息 struct WordInfo { let word: String let frame: CGRect } let glyphs: [GlyphInfo] let words: [WordInfo] let totalSize: CGSize // 计算文本的字符和单词排版数据 static func calculate(for text: String, font: NSFont) -> TextLayoutData { let attrStr = NSAttributedString(string: text, attributes: [.font: font]) let framesetter = CTFramesetterCreateWithAttributedString(attrStr) let path = CGPath(rect: CGRect(x: 0, y: 0, width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), transform: nil) let ctFrame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: 0), path, nil) var glyphs = [GlyphInfo]() var words = [WordInfo]() var totalWidth: CGFloat = 0 var totalHeight: CGFloat = 0 // 遍历所有行 let lines = CTFrameGetLines(ctFrame) as! [CTLine] for line in lines { let lineOrigin = CTFrameGetLineOrigins(ctFrame, CFRangeMake(0, 0), nil)[0] // 提取字符信息 let runs = CTLineGetGlyphRuns(line) as! [CTRun] for run in runs { let runFont = CTRunGetAttributes(run)[.font] as! NSFont let glyphCount = CTRunGetGlyphCount(run) for glyphIdx in 0..<glyphCount { let glyphRange = CFRangeMake(glyphIdx, 1) var pos = CGPoint.zero CTRunGetPositions(run, glyphRange, &pos) var rect = CGRect.zero CTRunGetBounds(run, glyphRange, &rect) let charIdx = CTRunGetStringRange(run).location + glyphIdx let char = String(text[text.index(text.startIndex, offsetBy: charIdx)]) let frame = CGRect( x: lineOrigin.x + pos.x + rect.origin.x, y: lineOrigin.y + pos.y + rect.origin.y, width: rect.width, height: rect.height ) glyphs.append(GlyphInfo(character: char, frame: frame)) totalWidth = max(totalWidth, frame.maxX) totalHeight = max(totalHeight, frame.maxY) } } // 提取单词信息(基于空格拆分,可根据需求优化分词逻辑) let lineStr = text[text.index(text.startIndex, offsetBy: CTLineGetStringRange(line).location)..<text.index(text.startIndex, offsetBy: CTLineGetStringRange(line).location + CTLineGetStringRange(line).length)] let lineWords = lineStr.split(separator: " ").map(String.init) var currentOffset: CGFloat = 0 for word in lineWords { let wordAttrStr = NSAttributedString(string: word + " ", attributes: [.font: font]) let wordLine = CTLineCreateWithAttributedString(wordAttrStr) let wordWidth = CTLineGetTypographicBounds(wordLine, nil, nil, nil) let frame = CGRect( x: lineOrigin.x + currentOffset, y: lineOrigin.y, width: wordWidth, height: font.lineHeight ) words.append(WordInfo(word: word, frame: frame)) currentOffset += wordWidth } } return TextLayoutData(glyphs: glyphs, words: words, totalSize: CGSize(width: totalWidth, height: totalHeight)) } }
2. 构建动画视图
struct AnimatedText: View { enum AnimationMode { case character, word } enum AnimationType { case fade case slideIn(edge: Edge) } let text: String let font: NSFont let mode: AnimationMode let type: AnimationType let interval: Double = 0.1 // 每个元素的动画间隔 @State private var displayedIndices: Set<Int> = [] private var layoutData: TextLayoutData { TextLayoutData.calculate(for: text, font: font) } var body: some View { ZStack(alignment: .topLeading) { // 透明占位文本,保证整体布局与原生一致 Text(text) .font(Font(font)) .opacity(0) // 逐字符动画 if mode == .character { ForEach(Array(layoutData.glyphs.enumerated()), id: \.offset) { idx, glyph in Text(glyph.character) .font(Font(font)) .position(x: glyph.frame.midX, y: glyph.frame.midY) .opacity(displayedIndices.contains(idx) ? 1 : 0) .offset(offsetForIndex(idx)) } } // 逐单词动画 else { ForEach(Array(layoutData.words.enumerated()), id: \.offset) { idx, word in Text(word.word) .font(Font(font)) .position(x: word.frame.midX, y: word.frame.midY) .opacity(displayedIndices.contains(idx) ? 1 : 0) .offset(offsetForIndex(idx)) } } } .frame(width: layoutData.totalSize.width, height: layoutData.totalSize.height) .onAppear { startAnimation() } } private func offsetForIndex(_ idx: Int) -> CGSize { guard !displayedIndices.contains(idx) else { return .zero } switch type { case .fade: return .zero case .slideIn(let edge): switch edge { case .leading: return CGSize(width: -20, height: 0) case .trailing: return CGSize(width: 20, height: 0) case .top: return CGSize(width: 0, height: -20) case .bottom: return CGSize(width: 0, height: 20) } } } private func startAnimation() { let count = mode == .character ? layoutData.glyphs.count : layoutData.words.count for idx in 0..<count { DispatchQueue.main.asyncAfter(deadline: .now() + Double(idx) * interval) { withAnimation(.easeInOut) { displayedIndices.insert(idx) } } } } }
使用示例
AnimatedText( text: "Hello macOS SwiftUI Animation", font: .systemFont(ofSize: 28, weight: .medium), mode: .character, type: .slideIn(edge: .leading) )
方案2:Canvas绘制(性能更优,适合长文本)
如果文本较长,创建大量Text视图会影响性能,可改用SwiftUI的Canvas批量绘制字符/单词,动画逻辑类似,但性能更好。
struct AnimatedTextCanvas: View { enum AnimationMode { case character, word } let text: String let font: NSFont let mode: AnimationMode @State private var progress: Double = 0 private var layoutData: TextLayoutData { TextLayoutData.calculate(for: text, font: font) } var body: some View { Canvas { context, size in let count = mode == .character ? layoutData.glyphs.count : layoutData.words.count let displayedCount = Int(Double(count) * progress) // 注意Canvas坐标与CoreText的转换(CoreText Y轴向上,Canvas Y轴向下) let yOffset = size.height - layoutData.totalSize.height if mode == .character { for idx in 0..<displayedCount { let glyph = layoutData.glyphs[idx] let attrStr = NSAttributedString(string: glyph.character, attributes: [.font: font]) context.draw(attrStr, at: CGPoint( x: glyph.frame.minX, y: yOffset + glyph.frame.minY )) } } else { for idx in 0..<displayedCount { let word = layoutData.words[idx] let attrStr = NSAttributedString(string: word.word, attributes: [.font: font]) context.draw(attrStr, at: CGPoint( x: word.frame.minX, y: yOffset + word.frame.minY )) } } } .frame(width: layoutData.totalSize.width, height: layoutData.totalSize.height) .onAppear { withAnimation(.easeInOut(duration: Double(layoutData.glyphs.count) * 0.1)) { progress = 1 } } } }
关键说明
- 排版准确性:所有元素的位置都来自CoreText的原生计算,完全还原系统文本的字距、连字等排版细节,解决HStack的问题。
- 动画灵活性:可以轻松扩展更多动画类型(如缩放、旋转),只需修改元素的opacity、offset或transform属性。
- 性能优化:短文本用ZStack+Text足够,长文本优先用Canvas,减少视图层级,提升渲染性能。
内容的提问来源于stack exchange,提问作者Marvin
相关产品推荐
相关产品推荐

