Playback speed
×
Share post
Share post at current time
0:00
/
0:00
Transcript

💡 SwiftUI FYI #12 - Countdown Timer

Interactive Countdown Timer

Achieving high precision countdown timers is not that easy… as it turns out!

In this example, I’ve faced the following Gotchas!

  1. Precision when toggling the timer is tricky. Hence, I made the timer fire every 0.01 seconds to achieve higher precision.

  2. Tracking milliseconds to avoid updating the UI every 0.01 seconds, and still achieving better precision.

  3. Stopping the timer at completion happens in the animation completion block, to ensure the final visuals take place. Otherwise due to the quick timer trigger, the final animation gets cancelled out, and the user will not see the final second count down.

I’ve also used a few tricks from previous posts, to get a nice count down effect such as .contentTransition(.numericText()), and my previous implementation of CircleProgressView

I hope you find this fun to play with and learn a few things!

🚨 Full code below and also on Github

Disclaimer: Do not use this code in production assuming it has 100% precision. It does not! It does have higher precision than most default implementations you will find.

🤝 Find more about me on saidmarouf.com. I’m also on XThreads, and LinkedIn

Thanks for reading Your Weekly SwiftUI FYI 💡! Subscribe for free to receive new posts and support my work.


import SwiftUI
import Combine

struct TimerView: View {
    
    let durationSeconds: Duration
    @State private var elapsedSeconds: Duration = .seconds(0)
    // For better interactivity precision
    @State private var elapsedMilliseconds: Int = 0
    @State private var cancellable: Cancellable?
    
    var body: some View {
        VStack(spacing: 24) {
            Spacer()
            ZStack {
                CircleProgressView(
                    progress: progress,
                    lineWidth: 6,
                    color: Color(hex: 0xFD8A06)
                )
                .frame(width: 240, height: 240)
                
                Text(durationSeconds - elapsedSeconds,
                  format: .time(pattern:   
                             .minuteSecond(padMinuteToLength: 2))
                )
                .font(.system(size: 50, weight: .medium))
                .foregroundStyle(.white.opacity(0.8))
                .contentTransition(.numericText())
                .monospaced()
            }
            
            HStack {
                // Cancel button
                if shouldShowCancelButton() {
                    Button {
                        stopAndResetTimer()
                    } label: {
                        Label("Start", systemImage: "xmark.circle.fill")
                            .font(.system(size: 70))
                            .symbolRenderingMode(.hierarchical)
                            .labelStyle(.iconOnly)
                            .tint(.red)
                    }
                }
                
                Spacer()
                
                // Toggle timer button
                Button {
                    toggleTimer()
                } label: {
                    Label("Start", systemImage: isTimerValid ?
                             "pause.circle.fill" : "play.circle.fill")
                        .font(.system(size: 70))
                        .symbolRenderingMode(.hierarchical)
                        .labelStyle(.iconOnly)
                        .tint(isTimerValid ? .orange : .green)
                }
            }
            .padding(.vertical, 44)
            .padding(.horizontal, 24)
        }
        .modifier(ImageBackground(imageName: "background3"))
    }
    
    // -MARK: Timer Controls
    
    private func toggleTimer() {
        isTimerValid ? pauseTimer() : startTimer()
    }
        
    private func startTimer() {
        
        cancellable = Timer
            .publish(every: 0.01, on: .main, in: .common)
            .autoconnect()
            .sink { _ in
                if newSecondPassed {
                    withAnimation(.easeInOut) {
                        if elapsedSeconds < durationSeconds {
                            elapsedSeconds += .seconds(1.0)
                        }
                    } completion: {
                        if shouldStopTimer {
                            stopAndResetTimer()
                        }
                    }
                }
            
                if !shouldStopTimer {
                    elapsedMilliseconds += 10
                }
            }
    }
    
    private var progress: Double {
        // Using milliseconds for smoother animation
        let elapsed: Duration = .milliseconds(elapsedMilliseconds)
        return (durationSeconds - elapsed) / durationSeconds
    }
    
    private var isTimerValid: Bool {
        cancellable != nil
    }

    private func shouldShowCancelButton() -> Bool {
        return isTimerValid || elapsedSeconds > .seconds(0)
    }
    
    private var shouldStopTimer: Bool {
        .milliseconds(elapsedMilliseconds) == durationSeconds
    }
    
    private var newSecondPassed: Bool {
        elapsedMilliseconds > 0 && elapsedMilliseconds % 1000 == 0
    }
    
    private func pauseTimer() {
        cancellable?.cancel()
        cancellable = nil
    }
    
    private func stopAndResetTimer() {
        pauseTimer()
        elapsedMilliseconds = 0
        withAnimation(.easeInOut) {
            elapsedSeconds = .seconds(0)
        }
    }
    
}

#Preview {
    TimerView(durationSeconds: .seconds(120))
}

Discussion about this podcast

Your Weekly SwiftUI FYI 💡
Your Weekly SwiftUI FYI 💡