SwiftUI 动画 - 了解如何处理状态更改/动画

问题描述 投票:0回答:1

我很难理解

SwiftUI
在我正在处理的具体示例中如何对视图变化进行动画处理。

TL;DR 问题描述和代码相当长。基本问题是:在这两种情况下,

.offset
VStack
都会发生变化并具有动画效果。为什么生成的动画会根据下面的文本而不同?


景色:

目标是创建一个

RollingCounter
视图:一种显示十进制值的文本,并通过上下“滚动”数字来动画值变化。

我的实现基于我在网上找到的一个想法:值文本被分成字符,并为每个数字创建一个

VStack
数字。根据需要显示的具体数字,使用
.offset
值来定位堆栈。


问题

为了测量文本的正确高度,使用视图

Text("X")
,然后用数字
VStack
覆盖。

简单地使用

Text("X")
效果很好,但会导致静态宽度对于像
1
或小数点分隔符
.
这样的数字来说可能太大。

为了解决这个问题,我使用

Text(valueParts[index])
,其中 valueParts[index] 是实际文本。

这会产生正确的宽度,但会导致动画中出现一些奇怪的行为。

虽然使用第一种方法 (

Text("X")
),当
VStack
时,
valueParts
可以很好地上下移动,并且
.offset
会发生变化,但当使用
Text(valueParts[index])
时,这不再起作用。效果很难描述,所以我制作了两张gif:

enter image description here enter image description here

使用第二种方法 (

Text(valueParts[index])
),
VStack
仍然可以很好地移动(如在其边界处看到的),一些包含的数字会乱序移动或根本不移动,但会淡入或淡出。

这是为什么?为什么动画会根据文本而变化?在这两种情况下,堆栈偏移量都会更改并且应该具有动画效果。这个动画是如何受到底层文本影响的?


代码

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: RollingDecimal.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()
}
ios swift swiftui swiftui-animation
1个回答
0
投票

为什么动画会根据文字而变化?

我想简短的答案是因为文本中字符的宽度不同。

当您有

Text("X")
时,所有字母将具有相同的宽度,即“X”的宽度。使用
Text(valueParts[index])
,宽度将根据索引处的值而变化。

一种可能的解决方法是通过在

.monospacedDigit()
修饰符之前添加
.overlay
修饰符来确保所有数字具有相同的宽度:

Text(valueParts[index])     // Approach 2
       .font(font)
    .opacity(0.2)
    .monospacedDigit() // <- try adding this
    .overlay {
    //...

要为每个数字的偏移设置动画,不需要

GeometryReader
。可以根据数字值本身和高度(基于字体大小)来计算。

理想情况下,滚动计数器视图应该只接受双精度作为参数,而不需要特殊设置,例如格式化程序状态。任何必要的格式化都应该发生在滚动计数器视图内。

.overlay

的使用是有问题的,我不确定它是否仅用于演示目的。基本上,所需要的只是改变偏移量和充当遮罩的固定框架。

实际的滚动数字基本上是(单个)动画数字的水平堆栈,因此应该有一个

RollingDigit

 视图接受单个数字或字符作为参数并相应地对其进行动画处理。这种方法还可以在每个数字的间距或样式方面提供更大的灵活性。

根据上述想法,这是我对滚动数字的看法:

import SwiftUI struct RollingNumberTest: View { //State values @State private var number: Double = 0.00 //Body var body: some View { VStack(spacing: 20) { //Number label Text("Number: \(number, format: .number.precision(.fractionLength(2)))") .foregroundStyle(.secondary) //Animated number RollingNumber(number: $number, fontSize: 30, spacing: 5) //Style as needed .fontDesign(.monospaced) .foregroundStyle(.white) .padding(.horizontal, 15) .background(.black, in: RoundedRectangle(cornerRadius: 12)) //Button to change value Button("Random number"){ number = Double.random(in: 0...20) } .buttonStyle(.borderedProminent) } } } struct RollingNumber: View { //Parameters @Binding var number: Double var fontSize: CGFloat = 30 var spacing: CGFloat = 0 //State values @State private var numberArray: [String] = [] //Body var body: some View { HStack(spacing: spacing) { ForEach(0..<numberArray.count, id: \.self) { index in RollingDigit(digit: $numberArray[index], fontSize: fontSize ) } } .font(.system(size: fontSize)) .onAppear { doubleToStringArray(number) } .onChange(of: number) { doubleToStringArray(number) } } //Helper method to convert a double to an array of string characters private func doubleToStringArray(_ number: Double) { let formattedNumber = String(format: "%.2f", number ) //Set the animation that affects number of values in array withAnimation(.interpolatingSpring) { numberArray = formattedNumber.map { String($0) } } } } struct RollingDigit: View { //Parameters @Binding var digit: String var fontSize: CGFloat = 30 //State values @State private var digitOffset: CGFloat = 0 //Computed properties private var digitHeight: CGFloat { // Calculate the height of the digit based on the font size return fontSize * 2 // Adjust as needed for padding } private var isDigit: Bool { //determines if digit or other character Int(digit) != nil ? true : false } //Body var body: some View { //Vertical stack for digits 0 through 9 VStack(spacing: 0) { Group { ForEach(0..<10) { number in Text("\(number)") } Text(".") //adds a period character at the end, after "9" } .frame(height: digitHeight) } .font(.system(size: fontSize) ) .monospacedDigit() // <- makes every digit same width .offset(y: digitOffset) .frame(height: digitHeight, alignment: .top) // Mask to show only one digit .clipped() .onAppear { //if not a digit if Int(digit) == nil { //Set the offset to the end to show the period character digitOffset = -10 * digitHeight } } .onChange(of: digit) { //Convert from String to Int let newValue = Int(digit) ?? 10 //Calculate the new offset based on the new value withAnimation(.interactiveSpring(duration: 2, extraBounce: 0.05)) { digitOffset = CGFloat(-newValue) * digitHeight } } } } //Previews #Preview { RollingNumberTest() }
    
© www.soinside.com 2019 - 2024. All rights reserved.