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