What You Will Build
A 25-minute Pomodoro-style focus timer with a circular progress ring that shrinks as time counts down. The ring changes color based on remaining time: green for plenty of time, orange for the middle stretch, and red for the final five minutes. Play/pause and reset controls let the user manage sessions, and a session counter tracks completed focus blocks.
Why This Pattern Matters
Circular progress indicators are everywhere in iOS: Activity Rings, download progress, and meditation timers. This tutorial teaches you how to combine Circle().trim() with a Timer publisher, plus dynamic color changes based on state.
Key SwiftUI Concepts
- Circle().trim(from:to:) for the progress ring, driven by remaining time as a fraction.
- Timer.scheduledTimer for the countdown logic.
- Computed property for dynamic color based on time thresholds.
Step 1: The Circular Progress Ring
ZStack {
// Background track
Circle()
.stroke(.gray.opacity(0.15), lineWidth: 12)
.frame(width: 220, height: 220)
// Progress ring
Circle()
.trim(from: 0, to: CGFloat(timeRemaining) / 1500.0)
.stroke(
timerColor.gradient,
style: StrokeStyle(lineWidth: 12, lineCap: .round)
)
.frame(width: 220, height: 220)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: timeRemaining)
// Time display
VStack(spacing: 4) {
Text(formatTime(timeRemaining))
.font(.system(size: 48, weight: .bold,
design: .monospaced))
Text(isRunning ? "Focusing..." : "Ready")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Step 2: Timer Logic and Controls
@State private var timeRemaining = 1500 // 25 min in seconds
@State private var isRunning = false
@State private var timer: Timer?
@State private var sessions = 0
// Play/Pause button action
Button {
isRunning.toggle()
if isRunning {
timer = Timer.scheduledTimer(
withTimeInterval: 1, repeats: true
) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
} else {
timer?.invalidate()
isRunning = false
sessions += 1
timeRemaining = 1500
}
}
} else {
timer?.invalidate()
}
} label: {
Image(systemName: isRunning ? "pause.fill" : "play.fill")
.font(.title)
.frame(width: 60, height: 60)
.background(timerColor, in: Circle())
.foregroundStyle(.white)
}
Step 3: Dynamic Color Based on Time
var timerColor: Color {
if timeRemaining > 900 { return .green } // > 15 min
if timeRemaining > 300 { return .orange } // > 5 min
return .red // final 5 min
}
func formatTime(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%02d:%02d", m, s)
}
Tips and Pitfalls
- rotationEffect(.degrees(-90)) makes the ring start from the top (12 o'clock position) instead of the default right side (3 o'clock).
- .gradient on the stroke color adds a subtle gradient along the ring for a polished look.
- Timer invalidation: Always call
timer?.invalidate()when pausing or resetting. Failing to do so creates multiple concurrent timers. - Background execution: This timer pauses when the app goes to background. For a production app, store the start time in
Dateand calculate remaining time on foreground return.
iOS Version: iOS 15+