iosswiftswiftuiswiftui-animation

SwiftUI animation - Understanding how state changes are handled / animated


I am struggeling to understand how exactly SwiftUI animates view changes in a concrete example I am working on.

TL;DR The description of the problem and the code is quite long. The basic question is: In both cases the .offset of the VStack changes and is animated. Why is the resulting animation different depending on the underling text?


The view:

The goal is to create a RollingCounter view: A kind of text shows decimal values and which animates value changes by "rolling" the digits up and down.

My implementation is based on an idea I found online: The value text is split up into its characters and for each digit a VStack of numbers is created. According to the concrete number that has to be shown, the stack is positioned using the .offset value.


The problem

To measure the correct height of the text a view Text("X") is used which is then overlayed with the digits VStack.

Simply using Text("X") works fine, but results in a static width which might be so large for numbers like 1 or the decimal separator ..

To solve this I use Text(valueParts[index]) where valueParts[index] is the actual text.

This results in the correct width but leads to some strange behavior in the animation.

While with the first approach (Text("X")) the VStack moves nicely up and down when valueParts and with it the .offset changes, this does not work anymore when Text(valueParts[index]) is used instead. The effect is hard to describe, so I created two gifs:

enter image description here enter image description here

With the second approach (Text(valueParts[index])) the VStack still moves nicely (as to be seen at its borders), some of the contained numbers move out of order or do not move at all but fade in or out.

Why is this? Why does the animation change depending on the text? In both cases the stack offset is changed and should animate. How is this animation influenced by the underlying text?


The Code

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()
}

Solution

  • Why does the animation change depending on the text?

    I suppose the short answer is because the width of the characters in the text varies.

    When you have Text("X"), all letters will have the same width, that of "X". With Text(valueParts[index]), the width will vary based on whatever value is at index.

    One possible fix is to ensure all digits have the same width, by adding the .monospacedDigit() modifier right before the .overlay modifier:

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

    To animate the offset of each digit, a GeometryReader is not needed. It can be calculated based on the digit value itself and the height (which is based on the font size).

    Ideally, the rolling counter view should simply accept a double as parameter, without requiring special setup, like a formatter state. Any essential formatting should happen inside the rolling counter view.

    The use of an .overlay is questionable, I wasn't sure if it was intended solely for the purposes of the demo or not. All that is needed is changing the offset and a fixed frame that acts as a mask, basically.

    The actual rolling number is basically a horizontal stack of (individual) animated digits, so there should be a RollingDigit view that accepts a single digit or character as parameter and animate it accordingly. This approach can also allow for more flexibility in terms of spacing or styling each individual digit.

    Here's my take on a rolling number, based on the ideas above:

    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()
    }