Learn how to create a SwiftUI “rolling number” animation that will amaze your users

Rotating numbers on a wall representing a rolling number animation

Overview

Have you ever seen one of those cool animations where the numbers count really fast in an app/game? Maybe you’re XP went up 100 or your bank account just had a deposit so the numbers slowly counted and increased and it was a really satisfying feeling. Or maybe you went to Ruth’s Chris and you got a $100 steak but didn’t feel the real pain until you checked your credit card app and you saw the dreaded red number increase a whole Benjamin?

Either way, the micro-interaction that the app used, gave you some kind of emotional attachment (good or bad) that made you remember that feeling. I know whenever I pay off my credit card bill, there is this really satisfying rolling number and I can visually see my credit limit increase. Every-time I see that animation, I’m instantly happy.

So, I decided to bring that happiness to you! YES, YOU! By creating a tutorial for how to add this awesome animation to your apps. MY goal for this blog is to recreate this animation and give you a building-block for creating emotion-packed micro-interactions in your application. Once you’re done reading this, you will be able to recreate the animation seen below ⬇️

SwiftUI rolling number animation demo
Sorry, have to compress the video because I need the extra money for coffee

Isn’t that an awesome micro-interaction? The best part is that it’s extremely versatile for various apps. Sports apps, fighting games, financial games, poker, you name it! Let’s get started!


Animation Breakdown

Before we can create the animation, we need to understand what the animation is doing. When I first got into iOS development, I rushed into creating animations without understanding the physical elements that were going on within them.

I should’ve been asking myself, “how are you supposed to create something when you don’t know the basics of it?”.

Well, I’m going to make sure you don’t run into that same mistake that I did. Since the micro-interaction we’re recreating is really two animations happening at once, we’ll show you the elements you need to create both of them and then merge them back together in the end. So, let’s break down each of the animations step by step so we can recreate them in code!

Scale over animation (green/red number pop-up)

  1. The basic number that we’re showing initially
  2. Button click (or some event)
  3. The animated number starts scaling, from size 0, from directly behind the shown number
  4. The animated number moves vertically above the shown number (while still scaling)
  5. At its peak displacement, it has a spring movement and comes to rest
  6. Pauses for a second
  7. Then scales back down to it’s starting place where it cannot be seen

Rolling number animation

  1. The basic number that we’re showing initially
  2. Button click (or some event)
  3. Every fraction, add a fraction of the value being added to the base number and then display that intermediary number
  4. Repeat step 3 until the final sum is reached

Now that we have the steps needed to create our awesome micro-interaction, let’s start writing the code to achieve it!


Code the scaling animation

The scaling animation is relatively simple. All we need to do is create a string that comes from behind something else (think ZStack) and scales up to a larger size than when it started (scaleEffect). As it’s scaling, the text needs to move (offset) from its starting position to a y-value less than its current position (because the top left of the screen is (0,0)). We then slap a simple spring animation on top of all of that and we get the awesome animation that you saw in the preview.

struct ScalingNumberAnimation: View {
    @State var showScaledNumber = false
    
    var body: some View {
        VStack {
            ZStack {
                // object to animate
                Text("$1000")
                    .font(.system(size: 18, weight: .medium, design: .serif))
                    .foregroundColor(.green)
                    .offset(x: 0, y: self.showScaledNumber ? -25 : -10)
                    .scaleEffect(self.showScaledNumber ? 1.25 : 0, anchor: .bottom)
                    .animation(Animation.spring(dampingFraction: 0.7).speed(0.8))
                
                // placeholder object
                Rectangle()
                    .foregroundColor(.white)
                    .frame(width: 100, height: 36)
            }
           
            Button(action: { self.showScaledNumber.toggle() }) {
                Text("Show Animation")
                    .padding()
                    .foregroundColor(.white)
                    .background(Color.blue)
                    .cornerRadius(10)
            }
        }
    }
}

The most important lines of code to look at are the modifiers for the TextView that is scaled. Notice that the y-offset and scale factor are variable because of the ternary operator. As a result, our view adapts it’s rendering when the @State variable changes, meaning the animation will be created for us!

Lastly, add a spring animation modifier to the view. The spring animation will give the View a little bounce when it gets to the top and will make it so it isn’t so stiff and linear 🤮. No-one likes stiff animations. Don’t be that developer making your users want to vomit!


Code the rolling number animation

Rolling number animations are a bit more complex than the scaling animation that we just created. To create the rolling number animation, we need to control time like we’re in the matrix.

I know what you’re probably thinking, “Brady, WTF do you mean control time?”. I’m not asking you to be Neo or anything, I’m just saying that we need to control when, in time, that actions take place on the screen. We can no longer just rely on a simple boolean “ON/OFF”. Instead, we now need to tell SwiftUI something like this, “Okay it’s been a fifth of a second, update the number for me”.

To do this, we’re going to use our trusty friend DispatchQueue. The DispatchQueue gives us an amazing function called asyncAfter which will allow us to run some code after a deadline. We can then use this to our advantage to split up the number that we’re adding into chunks, and add it over small intervals in time.

By doing this, we will give the user an illusion of the number having a “rolling counting” animation. This animation will have your users saying, “Recount it” 💸 in no time…

struct RollingCounterAnimation {
    @State var enteredNumber = 10
    @State var total = 0
    
    var body: some View {
        Text("\(total)")
            .font(.system(size: 18, weight: .medium, design: .serif))
            .foregroundColor(.green)
    }
    
    /// Creates a rolling animation while adding entered number
    func addNumberWithRollingAnimation() {
        withAnimation {
            // Decide on the number of animation steps
            let animationDuration = 1000 // milliseconds
            let steps = min(abs(self.enteredNumber), 100)
            let stepDuration = (animationDuration / steps)
            
            // add the remainder of our entered num from the steps
            total += self.enteredNumber % steps

            // For each step
            (0..<steps).forEach { step in
                // create the period of time when we want to update the number
                // I chose to run the animation over a second
                let updateTimeInterval = DispatchTimeInterval.milliseconds(step * stepDuration)
                let deadline = DispatchTime.now() + updateTimeInterval
                
                // tell dispatch queue to run task after the deadline
                DispatchQueue.main.asyncAfter(deadline: deadline) {
                    // Add piece of the entire entered number to our total
                    self.total += Int(self.enteredNumber / steps)
                }
            }
        }
    }
}

Within the code above, we really follow the steps that are outlined in the beginning.

First, we split the animation duration (1 sec) into steps which at max is 100 steps (this keeps from the rendering happening so fast it looks like it immediately goes to the final number). We then create a stepDuration from the animation duration / number of steps. The resulting quotient gives us the period of time that a single step will last. We then multiply the stepDuration by the step to get the timeInterval after .now() which will be when our code for the given step should run. (read that through one more time to make sure that is in your head because I know it’s a bit confusing!).

Lastly, we need to write the code that will be run after we wait for the given deadline. The code that will be run is simply adding the “chunk” of the entered number to our total (this could be any number in your app). To get this “chunk”, we simply divide the entered number by the number of steps (also note that we take care of remainders at the beginning). Remember that when we update a @State variable, the view will rerender to match the current value of the variable. As a result, the “new” total will then be visible on the screen for the stepDuration. Since the duration is so short, the human eye will think it’s running a counting animation!

All we have left to do is to put it together!


Putting it all together

Up until now, we’ve created the two animations separate from each-other. We did this because they are both capable of being performed without one another, and it allowed us to wrap our head around the building blocks needed to accomplish the task. But for this blog, it was our goal to run them at the same time, so it’s time to go big or go home. Let’s check out what that code would look like!

struct RollingNumberAnimation: View {
    @State var total = 0
    @State var enteredNumber = 10
    @State var showAnimation = false
    var backgroundColor = Color.white
    
    var body: some View {
        VStack {
            Text("Transaction Animator")
            Capsule()
                .foregroundColor(.clear)
            
            // Animation counting number
            ZStack(alignment: .center) {
                // current transaction "price"
                Text("$\(enteredNumber)")
                    .foregroundColor(enteredNunberIsPositive ? .green : .red)
                    .offset(x: 0, y: self.showAnimation ? -25 : -10)
                    .scaleEffect(self.showAnimation ? 1.25 : 0, anchor: .bottom)
                    .animation(Animation.spring(dampingFraction: 0.7).speed(0.8))
                
                Text("$\(total)")
                    // create a barrier from the total and scaled animation
                   .background(Rectangle().foregroundColor(self.backgroundColor))
            }
            .font(.system(size: 18, weight: .medium, design: .serif))
            
            
            Form {
                // transaction cost stepper
                Stepper(onIncrement: {
                    guard self.isAnimationAllowed else { return }
                    self.enteredNumber += 101
                }, onDecrement: {
                    guard self.isAnimationAllowed else { return }
                    self.enteredNumber -= 101
                }) {
                    Text("$\(enteredNumber)")
                }
                .padding(.horizontal)
                
                // button to add transaction
                Button(action: { self.addNumberWithRollingAnimation()}) {
                    Text("Submit Transaction")
                }
            }
        }
    }
}

extension RollingNumberAnimation {
    var enteredNunberIsPositive: Bool {
        enteredNumber > 0 // technically zero isn't positive lol
    }
    
    // guard against adding animation during another animation
    var isAnimationAllowed: Bool{
        !self.showAnimation
    }
    
    func addNumberWithRollingAnimation() {
        guard isAnimationAllowed else { return }
        
        withAnimation {
            // show scale animation
            self.showAnimation = true
            
            // Decide on the number of animation steps
            let animationDuration = 1000 // milliseconds
            let steps = min(abs(self.enteredNumber), 100)
            let stepDuration = (animationDuration / steps)
            
            // add the remainder of our entered num from the steps
            self.total += self.enteredNumber % steps
            
            (0..<steps).forEach { step in
                let updateTimeInterval = DispatchTimeInterval.milliseconds(step * stepDuration)
                let deadline = DispatchTime.now() + updateTimeInterval
                DispatchQueue.main.asyncAfter(deadline: deadline) {
                    // Add piece of the entire entered number to our total
                    self.total += Int(self.enteredNumber / steps)
                }
            }
            
            // hide scale animation
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.showAnimation = false
            }
        }
    }
}

In the code above I added some bells and whistles that I’ll let you check out. The big conceptual piece that I want you to notice is that the View is no different than the Views that we wrote before. We simply combined the animations from the views to create a complete micro-interaction! Just like that, you created an amazing interaction that your users will love.


Conclusion

We’ve seen how easy it can be to create awesome animations for your apps when you simply break down the animation into byte-sized chunks. Once you have those chunks, writing the code is easy as 123, and in an hour or less you have an awesome animation that your users will come love and hopefully become attached to!

Before you leave, I want to state that the code above is not perfect by any means. There are definitely some improvements that can be made to fit SWE principles, but that is going to be left as HW for you when you integrate it with your application. I also want you to try and tinker with the parameters for the spring animation. You will be amazed at how much a drastic change a small delta of 0.1 can make on the animation!

Let me know in the comments down below if you have any mico-interactions / animations that you love and I’ll try to recreate them live in a youtube special! 🍻

If you liked today’s content, make sure you subscribe to the newsletter down below and if you want to support my work @ unwrapped bytes, consider helping me out by buying me a coffee! Every dollar keeps me going to create more AWESOME FREE CONTENT FOR YOU! As always, thanks for taking the time to unwrap some bytes with me. Cheers! 

Processing…
Success! You're on the list.

Leave A Comment