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

SwiftUI实现多行文本动画:复刻笔记应用效果

在SwiftUI中实现笔记应用风格的多行文本动画

要实现的动画效果包含三个核心特性:

  • 文本区域高度随动画逐渐增加
  • 叠加从上到下移动的透明度渐变遮罩,模拟文本"逐渐显现"的效果
  • 文本行以波浪式的垂直偏移动画依次出现

现有基础实现

以下是初步实现的代码,能完成部分渐变和位移效果,但未实现行级的波浪式动画:

struct TextAnimation: View {
    @State private var animate = false
    @State private var removeGradient = false
    let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
    
    var body: some View {
        Text(text)
            .padding()
            .frame(height: 300)
            .foregroundStyle(
                LinearGradient(colors: removeGradient ? [Color.black] : [.blue, .red, .green, .yellow], startPoint: .top, endPoint: .bottom)
                    .opacity(animate ? 1 : 0.5)
            )
            .overlay {
                // 从上到下移动的白色渐变遮罩,模拟文本逐渐显现
                LinearGradient(colors: [.white.opacity(0.8)], startPoint: .top, endPoint: .bottom)
                    .offset(y: animate ? 300 : 0)
                    .animation(.easeIn(duration: 1), value: animate)
            }
            .onAppear {
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                    animate.toggle()
                }
                
                DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                    removeGradient.toggle()
                }
            }
    }
}

基于TextRenderer的完整实现

通过自定义TextRenderer和Transition,可以实现行级的波浪式垂直偏移动画,同时结合高度展开效果:

struct ContentView: View {
    @State private var isVisible: Bool = false

    let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "
    
    var body: some View {
        VStack {
            GroupBox {
                Toggle("显示文本", isOn: $isVisible.animation())
            }

            if isVisible {
                Text(text)
                    .font(.title2)
                    .bold()
                    .foregroundStyle(.blue)
                    .transition(TextTransition())
                    // 实现文本高度逐渐展开的动画
                    .frame(height: isVisible ? .infinity : 0)
                    .clipped()
                    // 可选:添加原实现中的渐变遮罩效果
                    .overlay {
                        LinearGradient(colors: [.white.opacity(0.8)], startPoint: .top, endPoint: .bottom)
                            .offset(y: isVisible ? 300 : 0)
                    }
            }
            Spacer()
        }
        .padding()
    }
}

// 自定义文本渲染器,实现行级波浪式弹入动画
struct AppearanceEffectRenderer: TextRenderer, Animatable {
    /// 动画已持续时长(可动画属性)
    var elapsedTime: TimeInterval

    /// 单行动画持续时长
    var elementDuration: TimeInterval

    /// 整个动画总时长
    var totalDuration: TimeInterval

    /// 弹簧动画配置,实现弹性偏移效果
    var spring: Spring {
        .snappy(duration: elementDuration - 0.05, extraBounce: 0.4)
    }

    // 绑定动画驱动值,实现Animatable协议
    var animatableData: Double {
        get { elapsedTime }
        set { elapsedTime = newValue }
    }

    init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) {
        self.elapsedTime = min(elapsedTime, totalDuration)
        self.elementDuration = min(elementDuration, totalDuration)
        self.totalDuration = totalDuration
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for (index, line) in layout.flattenedRuns.enumerated() {
            let delay = elementDelay(count: line.count)
            // 当前行的动画启动延迟时间
            let timeOffset = TimeInterval(index) * delay
            
            // 当前行已动画时长
            let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))
            
            // 复制上下文,避免行之间的动画影响
            var copy = context
            // 计算垂直偏移量,弹簧动画实现波浪式弹入效果
            let translationY = spring.value(
                fromValue: -line.typographicBounds.descent,
                toValue: 0,
                initialVelocity: 0,
                time: elementTime)
            copy.translateBy(x: 0, y: translationY)
            // 透明度渐变,实现行的淡入效果
            copy.opacity = UnitCurve.easeIn.value(at: elementTime / elementDuration)
            copy.draw(line)
        }
    }

    /// 计算行与行之间的动画启动延迟,保证总时长内完成所有行动画
    func elementDelay(count: Int) -> TimeInterval {
        let count = TimeInterval(count)
        let remainingTime = totalDuration - count * elementDuration

        return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count)
    }
}

// 扩展Text.Layout,简化文本行的遍历
extension Text.Layout {
    /// 获取所有文本行的便捷方法
    var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
        self.flatMap { line in
            line
        }
    }
}

// 自定义转场动画,将渲染器绑定到文本视图
struct TextTransition: Transition {
    static var properties: TransitionProperties {
        TransitionProperties(hasMotion: true)
    }

    func body(content: Content, phase: TransitionPhase) -> some View {
        let duration = 1.0
        let elapsedTime = phase.isIdentity ? duration : 0
        let renderer = AppearanceEffectRenderer(
            elapsedTime: elapsedTime,
            totalDuration: duration
        )

        content.transaction { transaction in
            // 强制使用线性动画驱动elapsedTime,确保弹簧动画节奏稳定
            if !transaction.disablesAnimations {
                transaction.animation = .linear(duration: duration)
            }
        } body: { view in
            view.textRenderer(renderer)
        }
    }
}

核心实现说明

  1. 高度展开动画:通过frame(height: isVisible ? .infinity : 0)和clipped()组合,实现文本区域随显示状态逐渐展开的效果。
  2. 渐变遮罩效果:可将原实现中的overlay逻辑整合到文本视图中,实现从上到下的显现动画。
  3. 波浪式行动画:AppearanceEffectRenderer遍历每一行文本,为每行添加延迟启动的弹簧垂直偏移动画,结合透明度渐变,实现行依次弹入的波浪效果。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 04:22:07