iosNudge

Countdown Timer

MediumSwiftUISwiftUIApp lifecycleConcurrency 40 min read

Build a countdown timer screen, like the Timer tab in the Clock app. The user picks a duration, then taps Start, Pause, Resume or Reset.

The hard part: the time must stay correct when the app is in the background. iOS stops (suspends) your app soon after the user leaves it. A counter that adds 1 every second also stops. Use dates instead.

@MainActor @Observable
final class CountdownTimer {
    func start(now: Date = .now)   // also resumes after a pause
    func pause(now: Date = .now)
    func reset()
    func remaining(at now: Date) -> TimeInterval
}

Example flow

  1. The user picks "1 min" and taps Start. The screen shows 01:00, then 00:59, 00:58…
  2. At 00:45 the user taps Pause. The time stops at 00:45.
  3. The user taps Resume. It continues from 00:45.
  4. At 00:40 the user goes to the home screen for 30 seconds.
  5. The user comes back. The screen shows 00:10 right away, not 00:40.
  6. At 00:00 it says "Time is up" and the phone gives a short vibration.

Requirements

  • Duration presets: 10 sec, 1 min, 5 min, 25 min. You cannot change them while running or paused.
  • Buttons: Start (idle or finished), Pause (running), Resume (paused), Reset (not idle).
  • Show the time as mm:ss. Show h:mm:ss for an hour or more.
  • Round up: with 0.4 s left, show 00:01, not 00:00.
  • A ring shows the progress.
  • Store the end date while running. Do not count ticks.
  • When the app comes back to the foreground, update at once.
  • When time is up, show "Time is up" and play a haptic.

What a good answer handles

  • Pause. A paused timer has no end date. Store the remaining time instead.
  • Battery. Do not redraw the screen while paused.
  • Testability. Pass now into the model so unit tests do not wait for real time.
  • VoiceOver. Read "Time remaining, 1 minute, 5 seconds", not "zero one colon zero five". Announce "Time is up".
  • Background alert. Say how you would alert the user if the time ends while the app is in the background.

Notes

  • Target iOS 17 or later. Use @Observable, TimelineView and .task(id:).
  • Timer is allowed for redrawing, but it must not be the source of truth for the time.

Starter code

Copy this into a new Xcode project (or Swift Playgrounds) and build from here.

import SwiftUI

// MARK: - Model

@MainActor
@Observable
final class CountdownTimer {
    // TODO: store dates, not a tick count.
    // Hint: idle, running(endDate:), paused(remaining:), finished.

    private(set) var duration: TimeInterval

    init(duration: TimeInterval = 60) {
        self.duration = max(duration, 1)
    }

    func start(now: Date = .now) {
        // TODO: start, or resume after a pause
    }

    func pause(now: Date = .now) {
        // TODO
    }

    func reset() {
        // TODO
    }

    func remaining(at now: Date) -> TimeInterval {
        // TODO
        duration
    }
}

// MARK: - View

struct CountdownView: View {
    @State private var timer = CountdownTimer(duration: 60)

    var body: some View {
        VStack(spacing: 32) {
            // TODO: redraw while running (TimelineView), show mm:ss and a progress ring.
            Text("01:00")
                .font(.system(size: 56, weight: .semibold, design: .rounded))
                .monospacedDigit()

            // TODO: duration presets, Start / Pause / Resume, Reset,
            // finish detection, background handling, accessibility.
            HStack {
                Button("Reset") { timer.reset() }
                Button("Start") { timer.start() }
            }
        }
        .padding()
    }
}

#Preview {
    CountdownView()
}

Solution

Try to build it first. Then compare your design with ours.