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

SwiftUI滚动文本动画在快速用户输入时卡顿问题解决

SwiftUI滚动数字动画快速交互卡顿修复

问题描述

我正在开发一款SwiftUI计数器应用,为数字实现了滚动文本动画。单次递增数字时动画运行正常,但用户快速连续多次输入增加数字时,动画会短暂卡顿后才恢复。以下是我的RollingText视图代码:

//
//  RollingText.swift
//

import SwiftUI

struct RollingText: View {
    var font: Font = .largeTitle
    var weight: Font.Weight = .regular

    @Binding var value: Int
    @State var animationRange: [Int] = []
    @State var isAnimating = false

    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<animationRange.count, id: \.self) { index in
                Text("8")
                    .font(font)
                    .fontWeight(weight)
                    .opacity(0)
                    .overlay {
                        GeometryReader { proxy in
                            let size = proxy.size

                            VStack(spacing: 0) {
                                ForEach(0...9, id: \.self) { number in
                                    Text("\(number)")
                                        .font(font)
                                        .fontWeight(weight)
                                        .frame(width: size.width, height: size.height, alignment: .center)
                                }
                            }
                            .offset(y: -CGFloat(animationRange[index]) * size.height)
                        }
                        .clipped()
                    }
            }
        }
        .onAppear {
            animationRange = Array(repeating: 0, count: "\(value)".count)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
                updateText()
            }
        }
        .onChange(of: value) { newValue in
            // 새 값에 따라 animationRange의 길이를 조정합니다.
            adjustAnimationRange(for: newValue)

            // 진행 중인 애니메이션에 대한 목표 값을 업데이트합니다.
            if isAnimating {
                for (index, value) in zip(0..<"\(newValue)".count, "\(newValue)") {
                    animationRange[index] = (String(value) as NSString).integerValue
                }
            } else {
                updateText()
            }
        }
    }
    
    func adjustAnimationRange(for newValue: Int) {
        let newCount = "\(newValue)".count
        let extra = newCount - animationRange.count
        if extra > 0 {
            animationRange += Array(repeating: 0, count: extra)
        } else if extra < 0 {
            animationRange.removeLast(-extra)
        }
    }

    func updateText() {
        isAnimating = true
        let stringValue = "\(value)"
        var longestAnimationDuration = 0.0

        for (index, value) in zip(0..<stringValue.count, stringValue) {
            let fraction = min(Double(index) * 0.15, 0.5)
            let animationDuration = 0.8 + fraction  // 이 값은 애니메이션의 실제 시간에 따라 조정해야 합니다.

            withAnimation(.interactiveSpring(response: 0.8, dampingFraction: 1 + fraction, blendDuration: 1 + fraction)) {
                animationRange[index] = (String(value) as NSString).integerValue
            }

            if animationDuration > longestAnimationDuration {
                longestAnimationDuration = animationDuration
            }
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + longestAnimationDuration) {
            isAnimating = false
        }
    }
}

struct RollingText_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

问题根源

当前实现的核心问题:

  • 快速连续输入时,全局isAnimating标记会直接修改目标值而非触发平滑过渡,打断原有动画的插值过程
  • 依赖DispatchQueue.main.asyncAfter延迟计时标记动画结束,无法精准追踪动画实际完成状态,导致状态不同步

修复方案

修改后的完整代码

import SwiftUI

struct RollingText: View {
    var font: Font = .largeTitle
    var weight: Font.Weight = .regular

    @Binding var value: Int
    @State private var animationRange: [Int] = []
    // 为每一位数字维护独立的动画状态
    @State private var digitAnimating: [Bool] = []

    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<animationRange.count, id: \.self) { index in
                Text("8")
                    .font(font)
                    .fontWeight(weight)
                    .opacity(0)
                    .overlay {
                        GeometryReader { proxy in
                            let size = proxy.size

                            VStack(spacing: 0) {
                                ForEach(0...9, id: \.self) { number in
                                    Text("\(number)")
                                        .font(font)
                                        .fontWeight(weight)
                                        .frame(width: size.width, height: size.height, alignment: .center)
                                }
                            }
                            .offset(y: -CGFloat(animationRange[index]) * size.height)
                        }
                        .clipped()
                        // 新增位数时添加淡入过渡
                        .opacity(digitAnimating.count > index ? 1 : 0)
                    }
            }
        }
        .onAppear {
            let initialString = "\(value)"
            animationRange = initialString.map { Int(String($0)) ?? 0 }
            digitAnimating = Array(repeating: false, count: initialString.count)
        }
        .onChange(of: value) { newValue in
            let newString = "\(newValue)"
            adjustAnimationRange(for: newString)
            updateText(to: newString)
        }
    }
    
    private func adjustAnimationRange(for newString: String) {
        let newCount = newString.count
        let currentCount = animationRange.count
        
        if newCount > currentCount {
            // 新增位数时初始化值,并添加淡入动画
            let extraDigits = newCount - currentCount
            animationRange += Array(repeating: 0, count: extraDigits)
            withAnimation(.easeInOut(duration: 0.3)) {
                digitAnimating += Array(repeating: true, count: extraDigits)
            }
        } else if newCount < currentCount {
            // 减少位数时直接移除末尾元素
            animationRange.removeLast(currentCount - newCount)
            digitAnimating.removeLast(currentCount - newCount)
        }
    }

    private func updateText(to targetString: String) {
        for (index, char) in targetString.enumerated() {
            guard let targetDigit = Int(String(char)) else { continue }
            let currentDigit = animationRange[index]
            
            // 处理循环滚动(比如9→0)的步数计算
            let steps = targetDigit >= currentDigit ? targetDigit - currentDigit : (10 - currentDigit) + targetDigit
            
            // 为每一位设置独立的动画参数
            let fraction = min(Double(index) * 0.15, 0.5)
            let animation = Animation.interactiveSpring(
                response: 0.6,
                dampingFraction: 0.8 + fraction,
                blendDuration: 0.5 + fraction
            )
            
            // 使用动画completion回调精准追踪单一位的动画结束状态
            withAnimation(animation) {
                animationRange[index] = targetDigit
            } completion: {
                digitAnimating[index] = false
            }
        }
    }
}

struct RollingText_Previews: PreviewProvider {
    static var previews: some View {
        @State var count = 0
        return VStack {
            RollingText(value: $count)
            Button("递增") {
                count += 1
            }
        }
    }
}

关键改进点

  • 移除全局isAnimating:改为为每一位数字维护独立的digitAnimating状态,避免批量动画冲突
  • 使用动画completion回调:精准追踪每一位动画的结束状态,替代不可靠的延迟计时
  • 优化滚动逻辑:处理数字循环滚动(9→0)的场景,确保动画自然衔接
  • 新增位数过渡动画:添加淡入效果,减少位数变化时的突兀感
  • 调整动画参数:优化弹簧动画的响应和阻尼值,提升快速交互时的流畅度

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.05 03:17:10