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

SwiftUI RollingCounter组件动画异常疑问:底层文本类型为何会影响VStack的offset动画表现?

SwiftUI RollingCounter组件动画异常疑问:底层文本类型为何会影响VStack的offset动画表现?

我最近在开发一个SwiftUI的RollingCounter组件,想实现数字滚动切换的动画效果——就是数值变化时,每个数字像滚轮一样上下滚动切换,而不是生硬跳转。

组件实现思路

我的实现逻辑是这样的:

  • 把要显示的数值通过格式化拆成单个字符(比如把"1.24"拆成["1", ".", "2", "4"])
  • 对每个数字字符,创建一个包含0到9所有数字的VStack
  • 根据当前要显示的数字,通过.offset(y:)来调整这个VStack的位置,让目标数字刚好显示在占位文本的位置
  • 非数字的字符(比如小数点)就直接显示对应的文本

遇到的奇怪问题

最开始我用固定的Text("X")作为占位文本,用来测量每个字符的高度,这时候动画完全正常——VStack会平滑地上下滚动,数字切换很丝滑。

但为了让每个字符的宽度适配实际内容(比如"1"比"X"窄,小数点更窄),我把占位文本改成了Text(valueParts[index]),也就是当前要显示的实际字符。这下问题来了:

  • VStack的边框看起来还是在正常上下移动,但里面的数字却出现了异常:有的数字乱序滚动,有的甚至直接淡入淡出,完全没有了原本流畅的滚动效果!

核心疑问

两种情况下,我都是修改VStack的.offset值,并且都用withAnimation包裹了状态更新。为什么仅仅是底层占位文本从固定的"X"换成了动态的实际字符,就会导致动画表现完全不同?

完整代码

struct RollingCounter: View {
    typealias Formatter = ((Double) -> String)

    var font: Font = .largeTitle
    
    @Binding var value: Double
    @Binding var formatter: ((Double) -> String)
    
    @State var valueParts: [String] = []

    
    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<valueParts.count, id: \.self) { index in
                //Text("X")                 // Approach 1
                Text(valueParts[index])     // Approach 2
                    //.id("part_\(valueParts.count - 1 - index)")
                    .font(font)
                    .opacity(0.2)
                    .overlay {
                        if let int = Int(valueParts[index]) {
                            GeometryReader { geometry in
                                VStack(spacing: 0) {
                                    ForEach(0...9, id: \.self) { number in
                                        Text("\(number)")
                                            //.id("\(index)_\(number)")
                                            .font(font)
                                            .border(Color.blue, width: 2)
                                    }
                                }
                                .border(Color.green, width: 1)
                                .offset(y: -CGFloat(CGFloat(int) * geometry.size.height))
                            }
                            //.clipped()
                        } else {
                            Text(valueParts[index])
                                .font(font)
                        }
                    }
                
            }
            .background(.red)
        }
        .onAppear {
            // Init with 0.00
            valueParts = formatter(value).map { if Int("\($0)") == nil { "\($0)" } else { "0" } }
            updateText()
        }
        .onChange(of: value) {
            updateText()
        }
    }
    
    func updateText() {
        for (index, part) in Array(formatter(value)).enumerated() {
            withAnimation(.easeInOut(duration: 5)) {
                valueParts[index] = "\(part)"
            }
        }
    }
}



struct TestContentView: View {
    
    @State var value: Double = 0
    @State var formatter: RollingCounter.Formatter = { value in
        let formatter = NumberFormatter()
        formatter.locale = Locale.init(identifier: "en_US")
        formatter.numberStyle = .currency
        formatter.currencySymbol = ""
        formatter.currencyCode = ""
        formatter.maximumFractionDigits = 2
        formatter.minimumFractionDigits = 2
        return formatter.string(from: NSNumber(value: value)) ?? ""
    }
    
    var body: some View {
        VStack(spacing: 25) {
            Button("Change Value") {
                value = (value == 1.24 ? 8.76 : 1.24) //.random(in: 1...1999)
            }
            
            RollingCounter(font: .system(size: 50), value: $value, formatter: $formatter)
        }
    }
}

#Preview {
    TestContentView()
}

备注:内容来源于stack exchange,提问作者Andrei Herford

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.15 09:09:36