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.
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.
Step 1: The Circular Progress Ring
ZStack {
Circle()
.stroke(.gray.opacity(0.15), lineWidth: 12)
.frame(width: 220, height: 220)
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)
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
@State private var isRunning = false
@State private var timer: Timer?
@State private var sessions = 0
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 }
if timeRemaining > 300 { return .orange }
return .red
}
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.
- .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 to avoid multiple concurrent timers.
- Background execution: This timer pauses when the app backgrounds. For production, store the start Date and calculate remaining time on return.
iOS Version: iOS 15+