Project 1 · Milestone 2 — the tip splitter
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?.
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.
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
Sliderbound totipPercentover0...30. Notice you did not add state — you reusedtipPercent. - Round-up mode: a toggle that rounds
perPersonup 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.