Achieving high precision countdown timers is not that easy… as it turns out!
In this example, I’ve faced the following Gotchas!
Precision when toggling the timer is tricky. Hence, I made the timer fire every 0.01 seconds to achieve higher precision.
Tracking milliseconds to avoid updating the UI every 0.01 seconds, and still achieving better precision.
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 X, Threads, and LinkedIn
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))
}
💡 SwiftUI FYI #12 - Countdown Timer