2

I want to animate a text that starts by default from 0, to a variable.

For example, for x = 80, I want my text to display all the numbers between 0 and 80 very fast, until it hits 80. I found examples with progress indicators, but I cannot apply the methods to this.

Do you have any ideas for doing this?

Thanks, Diocrasis.

5 Answers 5

6

Here's a solution that leverages SwiftUI's built-in animation capabilities. As a result, it doesn't need to manage any timers or animation loops itself.

It's not limited to moving from 0 to a final number, either. Any change in the value performed in a withAnimation block will animate the change in value by moving through intermediate values.

import SwiftUI

struct AnimatedNumberTextView<Content>: View, Animatable where Content: View {
    private var value: Double
    @ViewBuilder private let content: (Int) -> Content
    
    init(_ value: Int, content: @escaping (Int) -> Content) {
        self.value = Double(value)
        self.content = content
    }
    
    var animatableData: Double {
        get { value }
        set { value = newValue }
    }
    
    var body: some View {
        content(Int(value))
    }
}


struct ContentView: View {
    @State private var number = 0
    
    var body: some View {
        HStack {
            Text("Riches:")

            AnimatedNumberTextView(number) { value in
                Text("\(value) 😮")
                    .monospaced()
            }.frame(minWidth: 140, alignment: .trailing)
        }
        .padding(.bottom)
        .onAppear {
            withAnimation(.easeInOut(duration: 2)) {
                number = 87654321
            }
        }
        
        Button("Randomise!") {
            withAnimation(.easeInOut(duration: 1)) {
                number = Int.random(in: 0...87654321)
            }
        }
    }
}

(Inspired by this blog and this blog.)

Sign up to request clarification or add additional context in comments.

Comments

4

Here I've created a little function called runCounter which takes a binding to the counter variable, a start value, the end value, and the speed. When called, it sets the bound variable to the start value, and then starts a Timer which runs every speed seconds and increments the counter until it reaches end at which point it invalidates the timer.

This standalone example shows two counters running at different speeds, both of which start when they first appear using .onAppear().

struct ContentView: View {
    @State private var counter1 = 0
    @State private var counter2 = 0
    
    var body: some View {
        VStack {
        Text("\(self.counter1)")
            .onAppear {
                self.runCounter(counter: self.$counter1, start: 0, end: 80, speed: 0.05)
            }
        Text("\(self.counter2)")
            .onAppear {
                self.runCounter(counter: self.$counter2, start: 0, end: 10, speed: 0.5)
            }
        }
    }
    
    func runCounter(counter: Binding<Int>, start: Int, end: Int, speed: Double) {
        counter.wrappedValue = start

        Timer.scheduledTimer(withTimeInterval: speed, repeats: true) { timer in
            counter.wrappedValue += 1
            if counter.wrappedValue == end {
                timer.invalidate()
            }
        }
    }
}

2 Comments

This is a significant improvement, but even setting the speed to 0.0001 is still too slow when counting large numbers.
I am using this in a project. I posted tweaked code below where I set the maximum number of steps required to get from 0 to the end number.
2

You can use a Timer.Publisher to trigger incrementing of your counter at regular intervals.

To stop incrementing once you reach your desired count, whenever your Timer fires, you can check if count has reached end, if not, increment it, otherwise remove the subscription and hence stop incrementing.

class Counter: ObservableObject {
    @Published var count = 0
    let end: Int

    private var timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
    private var subscriptions = Set<AnyCancellable>()

    init(end: Int) {
        self.end = end
    }

    func start() {
        timer.sink { [weak self] _ in
            guard let self = self else { return }
            if self.count <=self.end {
                self.count += 1
            } else {
                self.subscriptions.removeAll()
            }
        }.store(in: &subscriptions)
    }
}

struct AnimatedText: View {
    @ObservedObject var counter: Counter

    var body: some View {
        Text("\(counter.count)")
            .onAppear() {
                self.counter.start()
            }
    }
}


struct AnimatedText_Previews: PreviewProvider {
    static var previews: some View {
        AnimatedText(counter: Counter(end: 80))
    }
}

3 Comments

your code is finishing on 81 and not on 80, and 0.5 seconds between updates is really not considered 'very fast', i changed it to 0.1 and it is still too slow for a counter something like this: static.schoolofmotion.com/f52be17e-b040-46b7-b835-672d9b3da47b/…
In case you're wondering to use AnimatedText inside another View, the counter variable in AnimatedText should be defined as @StateObject and not ObservedObject. This fixed my issue because of how StateObjects are able to redraw only part of a view
@Shaybc you can update the timer to publish every 0.001 seconds or something like that to your liking if you want it to count up faster. Also update the if condition inside start function to be if self.count < self.end and that should fix the problem 80 vs 81 as well. And please see my comment above for embedding this inside another View so that it works well.
0

Adding to the answer by @vacawama.

func runCounter(counter: Binding<Int>, start: Int, end: Int, speed: Double) {
    let maxSteps = 20
    counter.wrappedValue = start
    
    let steps = min(abs(end), maxSteps)
    var increment = 1
    if steps == maxSteps {increment = end/maxSteps}

    Timer.scheduledTimer(withTimeInterval: speed, repeats: true) { timer in
        counter.wrappedValue += increment
        if counter.wrappedValue >= end {
            counter.wrappedValue = end
            timer.invalidate()
        }
    }
}

Comments

0

How about this simple solution with a timer:

extension Timer {
    static func scheduledProgressTimer(duration: TimeInterval, block: @escaping ((_ progress: Double) -> Void), completion: (() -> Void)? = nil) {
        let startDate = Date()
        Timer.scheduledTimer(withTimeInterval: 1.0 / 60, repeats: true) { timer in
            let interval = Date().timeIntervalSince(startDate)
            let progress = min(1.0, abs(interval / duration))
            block(progress)
            if progress >= 1.0 {
                timer.invalidate()
                completion?()
            }
        }
    }
}

and then:

struct TestView: View {
    @State var counter: Int = 0
    
    var body: some View {
        VStack {
            Text("Counter: \(counter)")
            Button("Start counting") {
                let endValue = 80
                Timer.scheduledProgressTimer(duration: 2) { progress in
                    counter = Int(progress * Double(endValue))
                } completion: {
                    print("Finished!")
                }
            }
        }
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.