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 Date and calculate remaining time on foreground return.

iOS Version: iOS 15+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.