Project 1 · Milestone 1 — the counterswift-6.4/ios-26
Lesson 2 / 4
Unit 21 · Counter+ / Tip Splitter

Project 1 · Milestone 1 — the counter

Note

Unit 21 · Project 1 · Milestone 1. You build the Counter+ screen end to end. By the end your app increments, decrements with a floor of zero, uses an adjustable step, and resets.

The counter is where you internalize SwiftUI's core loop. @State holds the number, the body reads it, buttons mutate it, and the frame re-renders. Type every line — Tutor stage.

Step 1 — the state and the label

Create CounterView. A view's private, view-owned data is @State:

import SwiftUI

struct CounterView: View {
    @State private var count = 0
    @State private var step = 1

    var body: some View {
        VStack(spacing: 32) {
            Text(count, format: .number)
                .font(.system(size: 80, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
                .animation(.snappy, value: count)
        }
        .padding()
    }
}

Text(count, format: .number) renders an Int without you converting to String by hand. .contentTransition(.numericText()) makes the digits roll when the value changes — a free polish that ships with the platform.

Step 2 — the buttons

Add an HStack of two buttons below the label. A Button's action closure mutates the state, and the view re-renders because count is @State:

HStack(spacing: 24) {
    Button {
        count = max(0, count - step)
    } label: {
        Image(systemName: "minus")
            .frame(width: 64, height: 64)
    }
    .buttonStyle(.bordered)
    .disabled(count == 0)

    Button {
        count += step
    } label: {
        Image(systemName: "plus")
            .frame(width: 64, height: 64)
    }
    .buttonStyle(.borderedProminent)
}
.font(.title2)

Two things worth naming. max(0, count - step) clamps the floor — the count never goes negative. .disabled(count == 0) reads state to gray out the minus button when it would do nothing; the UI reflecting what is possible is a habit, not decoration.

Note

Do not track "can decrement" in a second @State bool. Derive it — count == 0 is the truth, and duplicating it into a separate stored flag is exactly the kind of drift that causes "the button is enabled but does nothing" bugs. State you can compute, you compute.

Step 3 — the step control and reset

Let the user change how much each tap moves. A Stepper binds directly to step:

Stepper("Step: \(step)", value: $step, in: 1...10)
    .padding(.horizontal)

Button("Reset", role: .destructive) {
    count = 0
}
.disabled(count == 0)

The $step is a bindingStepper needs read and write access, and the $ projects @State into a Binding<Int> it can mutate. role: .destructive tints Reset appropriately and communicates intent to VoiceOver.

Step 4 — assemble and accessibilize

Wrap the label, the button row, the stepper, and reset in the outer VStack, with a Spacer() above and below the number to center it vertically. Then give the interactive controls VoiceOver labels so the icon-only buttons announce their purpose:

Button { count += step } label: {
    Image(systemName: "plus").frame(width: 64, height: 64)
}
.accessibilityLabel("Increment by \(step)")

An SF Symbol with no label reads as "plus" to VoiceOver — technically true, useless in context. "Increment by 2" tells the user what will happen.

Checkpoint

Your app should now:

  • Show a large count starting at 0 that rolls when it changes.
  • Increment by the step, decrement without ever going below 0.
  • Disable minus and Reset when the count is 0.
  • Let you set the step from 1 to 10 with the stepper.
  • Read correctly in both light and dark mode (you wrote zero colors — the system semantic colors handle both).

Run it on the simulator, toggle dark mode (Settings or the Xcode environment override), and confirm nothing looks wrong.

Stretch goals

  • Add a long-press on the number to reset, with .onLongPressGesture, keeping the button too. Notice how gestures compose with the existing state.
  • Add a small history line: "last change: +2" using a second computed or stored value. Decide honestly whether it needs @State or can be derived.
  • Add haptic feedback with .sensoryFeedback(.increase, trigger: count) and feel how a trigger-based modifier reacts to state changes without you calling anything imperatively.

Knowledge check

Q: Why clamp with max(0, count - step) instead of an if count > 0 guard around the mutation? Both work, but max(0, …) states the invariant (the floor is 0) in one expression and can't be accidentally bypassed by another code path. Clamping at the point of assignment keeps the rule next to the value it protects.

Q: What does $step give the Stepper that step alone would not? A Binding<Int> — two-way access. step is just the current value (read-only); $step lets the Stepper write back into your @State, which is what a control needs.