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

如何实现类似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
            }
        }
    }
}

关键说明

  1. 排版准确性:所有元素的位置都来自CoreText的原生计算,完全还原系统文本的字距、连字等排版细节,解决HStack的问题。
  2. 动画灵活性:可以轻松扩展更多动画类型(如缩放、旋转),只需修改元素的opacity、offset或transform属性。
  3. 性能优化:短文本用ZStack+Text足够,长文本优先用Canvas,减少视图层级,提升渲染性能。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.18 09:25:06