How to Build a Tip Calculator App in SwiftUI
A Tip Calculator app lets diners instantly split the bill, choose a tip percentage, and see exactly what each person owes — no mental math required. It's an ideal first SwiftUI project for developers who want a polished, shippable app in a weekend.
Prerequisites
- Mac running macOS Sequoia or later with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI familiarity (views, state, bindings)
- No external APIs or device hardware needed — the iOS Simulator covers everything
Architecture overview
A Tip Calculator is a pure computation app: all state lives in a single @Observable view-model (BillModel) that holds the raw bill amount, tip percentage, and party size. SwiftUI's Form components bind directly to that model, and computed properties derive every output value — tip amount, total, and per-person share — with no persistence layer needed. There are no network calls or background tasks; the app is entirely reactive and synchronous.
TipCalculator/
├── TipCalculatorApp.swift # @main entry point
├── Models/
│ └── BillModel.swift # @Observable: amount, tipPct, partySize
├── Views/
│ ├── ContentView.swift # NavigationStack root
│ ├── BillFormView.swift # Form: inputs
│ └── ResultCardView.swift # Summary: tip, total, per-person
├── PrivacyInfo.xcprivacy # Required for App Store
└── Assets.xcassets/
└── AppIcon.appiconset/
Step-by-step
1. Create the Xcode project
Open Xcode 16, choose File → New → Project, pick the iOS App template, set the interface to SwiftUI, and leave "Include Tests" checked. Name it TipCalculator with a bundle ID like com.yourname.tipcalculator. No SwiftData framework needed for this app — all state is in-memory.
// TipCalculatorApp.swift
import SwiftUI
@main
struct TipCalculatorApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
2. Define the data model with @Observable
Use the @Observable macro (Swift 5.9+, iOS 17+) to create a lightweight model class. Computed properties do all the math so your views stay declarative and free of logic.
// Models/BillModel.swift
import Observation
@Observable
final class BillModel {
var billAmount: Double = 0
var tipPercentage: Double = 18 // default 18 %
var partySize: Int = 2
// MARK: - Computed
var tipAmount: Double {
billAmount * (tipPercentage / 100)
}
var totalAmount: Double {
billAmount + tipAmount
}
var amountPerPerson: Double {
guard partySize > 0 else { return 0 }
return totalAmount / Double(partySize)
}
var tipAmountPerPerson: Double {
guard partySize > 0 else { return 0 }
return tipAmount / Double(partySize)
}
// Common tip options shown as a Picker
static let tipOptions: [Double] = [10, 15, 18, 20, 25]
}
3. Build the main Form UI
SwiftUI's Form and Section give you a native iOS look for free. Bind the text field to billAmount using a NumberFormatter, use a Picker for tip percentage, and a Stepper for party size.
// Views/BillFormView.swift
import SwiftUI
struct BillFormView: View {
@Bindable var model: BillModel
var body: some View {
Form {
Section("Bill") {
HStack {
Text("$")
.foregroundStyle(.secondary)
TextField("0.00", value: $model.billAmount,
format: .number.precision(.fractionLength(2)))
.keyboardType(.decimalPad)
}
}
Section("Tip") {
Picker("Tip percentage", selection: $model.tipPercentage) {
ForEach(BillModel.tipOptions, id: \.self) { pct in
Text("\(Int(pct))%").tag(pct)
}
}
.pickerStyle(.segmented)
// Custom tip slider
VStack(alignment: .leading, spacing: 4) {
Text("Custom: \(Int(model.tipPercentage))%")
.font(.caption)
.foregroundStyle(.secondary)
Slider(value: $model.tipPercentage,
in: 0...50, step: 1)
}
}
Section("Split") {
Stepper("Party size: \(model.partySize)",
value: $model.partySize,
in: 1...20)
}
}
}
}
#Preview {
BillFormView(model: BillModel())
}
4. Implement bill splitting with a results card
This is the core feature: a live results card that updates every time the user adjusts any input. Wire everything together in ContentView and show the per-person breakdown prominently so it's scannable at a glance.
// Views/ResultCardView.swift
import SwiftUI
struct ResultCardView: View {
let model: BillModel
var body: some View {
VStack(spacing: 0) {
// Hero: per-person amount
VStack(spacing: 4) {
Text("Each person pays")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(model.amountPerPerson,
format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
.font(.system(size: 52, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
}
.padding(.vertical, 24)
Divider()
// Breakdown grid
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
GridRow {
label("Bill")
value(model.billAmount)
}
GridRow {
label("Tip (\(Int(model.tipPercentage))%)")
value(model.tipAmount)
}
GridRow {
label("Total")
value(model.totalAmount)
.fontWeight(.semibold)
}
if model.partySize > 1 {
Divider()
.gridCellUnsizedAxes(.horizontal)
GridRow {
label("Tip / person")
value(model.tipAmountPerPerson)
}
}
}
.padding(20)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20))
.padding(.horizontal)
}
private func label(_ text: String) -> some View {
Text(text).foregroundStyle(.secondary).font(.callout)
}
private func value(_ amount: Double) -> some View {
Text(amount,
format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
.font(.callout)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
// Views/ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var model = BillModel()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
BillFormView(model: model)
.frame(minHeight: 300)
ResultCardView(model: model)
.padding(.bottom, 32)
}
}
.navigationTitle("Tip Calculator")
.background(Color(.systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Reset") {
model = BillModel()
}
.foregroundStyle(.red)
}
}
}
}
}
#Preview {
ContentView()
}
5. Add a Privacy Manifest (required for App Store)
Apple requires a PrivacyInfo.xcprivacy file in every app submitted to the App Store. Even if your app collects no data, you must declare that explicitly. Missing this file will block your submission with a policy rejection.
<!-- PrivacyInfo.xcprivacy (add via File → New → File → App Privacy) -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- No data collected by this app -->
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<!-- No tracking -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<!-- Required reason APIs used (none for this app) -->
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
<!--
NOTE: If you add AdMob or any ad SDK later, you MUST update
NSPrivacyTracking to <true/> and list the ad network domains
in NSPrivacyTrackingDomains. Failing to do so causes App Store
rejection even after initial approval.
-->
Common pitfalls
- Floating-point currency display. Never format currency with
String(format: "%.2f", amount). Always use.formatted(.currency(code:))orNumberFormatter— raw floating-point strings skip locale separators and fail App Store review screenshot checks in non-US regions. - Keyboard blocks the results card. Wrapping your view in a plain
VStackcauses the results card to disappear behind the decimal keyboard. UseScrollViewas the root container so the user can scroll to see results while typing. - @Observable on iOS 16 builds. The
@Observablemacro requires iOS 17+. If your Deployment Target is set lower, Xcode will give a cryptic linker error. Set minimum deployment to iOS 17 in project settings from the start. - App Store rejection: missing ad SDK privacy declarations. If you later add Google AdMob, you must update
PrivacyInfo.xcprivacywith tracking domains and addNSUserTrackingUsageDescriptiontoInfo.plist. Submitting with an ad SDK but no ATT prompt causes an automatic rejection on first review. - Stepper party size allows zero. A
Stepperwithin: 0...20lets users reach zero, causing a divide-by-zero inamountPerPerson. Always set the lower bound to1and guard against it in your computed property regardless.
Adding monetization: Ad-supported
The simplest approach for a free Tip Calculator is a single banner ad at the bottom of the scroll view using Google AdMob's GADBannerView wrapped in a UIViewRepresentable. Add the GoogleMobileAds Swift Package from https://github.com/googleads/swift-package-manager-google-mobile-ads, initialise the SDK in your @main App's init() with GADMobileAds.sharedInstance().start(), and present a GADBannerView with size .banner pinned to the bottom of the screen. Be sure to add your AdMob App ID to Info.plist under GADApplicationIdentifier, update PrivacyInfo.xcprivacy to declare tracking and list googleadservices.com in NSPrivacyTrackingDomains, and show Apple's ATT prompt (ATTrackingManager.requestTrackingAuthorization) before the first ad request — skipping this step will get your app rejected and can result in significantly lower eCPM even after approval.
Shipping this faster with Soarias
Soarias automates the four most time-consuming steps outside of writing code: project scaffolding (generates the Xcode project, folder structure, and PrivacyInfo.xcprivacy in one command), fastlane lane setup for TestFlight and production builds, App Store Connect metadata entry (screenshots, description, keywords, age rating), and the final submission API call. For a Tip Calculator you'd typically spend 30–60 minutes fumbling through ASC's UI and fastlane configuration; Soarias handles all of it in a single guided flow.
At beginner complexity, the app logic itself takes a weekend. Without Soarias, the surrounding App Store busywork — provisioning profiles, manual screenshot uploads, privacy nutrition labels — can add another 3–5 hours for a first-time shipper. With Soarias, that overhead drops to under 15 minutes, which means you can go from working simulator to live TestFlight link in the same afternoon you finish the last step above.
Related guides
FAQ
Does this work on iOS 16?
No — this tutorial uses the @Observable macro and the #Preview macro, both of which require iOS 17 and Xcode 15+. If you need iOS 16 support, replace @Observable with @ObservableObject / @Published and swap #Preview for PreviewProvider. Given that iOS 17+ adoption is above 90% as of 2026, targeting iOS 17+ is the right call for new apps.
Do I need a paid Apple Developer account to test?
No — you can run the app on a physical iPhone for free by signing with a personal team in Xcode (Settings → Accounts → add your Apple ID). Free provisioning expires every 7 days and doesn't allow App Store distribution or TestFlight, but it's sufficient for local development and testing of all the features in this guide.
How do I add this to the App Store?
You'll need an Apple Developer Program membership ($99/year), an app record created in App Store Connect, at least one set of App Store screenshots (iPhone 6.9" required), a completed Privacy Nutrition Label declaration, and a binary uploaded via Xcode Organizer or fastlane. The quickest path is to use Soarias, which walks through each of these steps and submits on your behalf via the ASC API.
How do I handle currencies other than USD?
The code above uses Locale.current.currency?.identifier ?? "USD", which automatically picks the device's locale currency for display. If you want to let users choose a currency manually, add a currencyCode: String property to BillModel, populate a Picker from Locale.commonISOCurrencyCodes, and pass the selected code to the .currency(code:) format style. This is a natural next step after the beginner version ships.
Last reviewed: 2026-05-12 by the Soarias team.