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
相关产品推荐
相关产品推荐

