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

Project 1 · Milestone 2 — the tip splitter

Note

Unit 21 · Project 1 · Milestone 2. You build the Tip Splitter screen. By the end you enter a bill, pick a tip, set the party size, and see tip, total, and per-person — all correct, currency-formatted, and safe against the divide-by-zero edge.

The counter taught state-in, view-out. The tip splitter adds the other half of the loop: derived values. The inputs are stored; every output is computed from them. If you find yourself storing a total, you have already made a mistake — the total is a function of the inputs, and functions of state belong in computed properties, not in more state.

Step 1 — the inputs

Three pieces of stored state: the subtotal, the tip percentage, and the party size.

import SwiftUI

struct TipSplitterView: View {
    @State private var subtotal: Decimal? = nil
    @State private var tipPercent = 20
    @State private var partySize = 1

    var body: some View {
        Form {
            Section("Bill") {
                TextField("Subtotal", value: $subtotal, format: .currency(code: "USD"))
                    .keyboardType(.decimalPad)
            }
        }
    }
}

Two deliberate choices. Money is Decimal, never Double — binary floating point can't represent 0.10 exactly, and you do not want rounding drift in someone's bill. And subtotal is optional so an empty field is nil, not a misleading 0. TextField's value: + format: initializer parses and formats through the same FormatStyle, so the field shows currency and hands you back a Decimal?.

Note

Never model money as Double. 0.1 + 0.2 != 0.3 in Double, and across a tip calculation those errors accumulate into a total that is off by a cent — the exact bug a user notices. Decimal is base-10 and exact for this. Import Foundation (SwiftUI re-exports it) to use it.

Step 2 — the tip and party controls

Add a tip picker and a party stepper in their own sections:

Section("Tip") {
    Picker("Tip percentage", selection: $tipPercent) {
        ForEach([15, 18, 20, 25], id: \.self) { pct in
            Text("\(pct)%").tag(pct)
        }
    }
    .pickerStyle(.segmented)
}

Section("Party") {
    Stepper("Party of \(partySize)", value: $partySize, in: 1...50)
}

The in: 1...50 on the stepper is your first line of defense against the divide-by-zero edge — the party can never reach 0. Defense in depth comes next, in the computation.

Step 3 — the derived values

Here is the milestone's spine. Tip, total, and per-person are computed properties, not stored state:

private var billAmount: Decimal { subtotal ?? 0 }

private var tipAmount: Decimal {
    billAmount * Decimal(tipPercent) / 100
}

private var total: Decimal {
    billAmount + tipAmount
}

private var perPerson: Decimal {
    total / Decimal(max(partySize, 1))
}

Each reads the current state and returns a fresh answer every time the body renders. Change any input and all three update, because the body re-reads them — you never write "update the total" anywhere. max(partySize, 1) is the belt to the stepper's suspenders: even if some future refactor lets partySize reach 0, the division stays safe.

Step 4 — the results section and currency formatting

Render the outputs, formatting each Decimal as currency:

Section("Result") {
    LabeledContent("Tip", value: tipAmount, format: .currency(code: "USD"))
    LabeledContent("Total", value: total, format: .currency(code: "USD"))
    LabeledContent("Per person", value: perPerson, format: .currency(code: "USD"))
        .font(.headline)
}

.currency(code:) places the symbol, groups thousands, and rounds to the currency's minor units — all locale-aware, all for free. LabeledContent gives you the aligned label/value row Apple uses in Settings, and it reads well to VoiceOver as a pair.

Tip

Hard-coding "USD" is fine for this project. To follow the device, use .currency(code: Locale.current.currency?.identifier ?? "USD"). Try switching the simulator's region and watch the symbol and grouping change — that is FormatStyle doing the locale work you would otherwise hand-write.

Step 5 — the empty state

When subtotal is nil, the results are all zero, which is honest but not helpful. Show a prompt instead of a wall of $0.00:

if subtotal == nil {
    Section {
        ContentUnavailableView(
            "Enter a bill",
            systemImage: "dollarsign.circle",
            description: Text("Type a subtotal to see the split.")
        )
    }
} else {
    // the Result section from Step 4
}

ContentUnavailableView is the platform's standard empty state — you get correct spacing, typography, and dark-mode treatment without designing it yourself.

Checkpoint

Your app should now:

  • Accept a subtotal, defaulting to an empty field (not $0.00).
  • Let you pick 15/18/20/25% and a party from 1 to 50.
  • Show tip, total, and per-person, each recomputed live as inputs change.
  • Format every amount as locale-aware currency.
  • Show an empty-state prompt when no subtotal is entered.
  • Never divide by zero, and read correctly in light and dark mode.

Test the canonical case: $73.40, 20%, party of 3 → tip $14.68, total $88.08, per person $29.36.

Stretch goals

  • Add a custom-tip path: a fourth picker segment "Custom" that reveals a Slider bound to tipPercent over 0...30. Notice you did not add state — you reused tipPercent.
  • Round-up mode: a toggle that rounds perPerson up to the next dollar and shows the extra the party is leaving. Keep it a computed value.
  • Show the effective tip percentage after rounding, proving your derived values compose.

Knowledge check

Q: Why is total a computed property instead of @State? Because it is entirely a function of subtotal, tipPercent, and party — derived data. Storing it means keeping it in sync by hand on every input change, which is where staleness bugs come from. Computing it means it is correct by construction.

Q: Why Decimal for money and Decimal? for the subtotal specifically? Decimal is exact base-10 arithmetic, so cents never drift the way Double does. Making it optional lets an empty field be genuinely empty (nil) rather than a misleading 0, which drives the empty-state UI honestly.