```html How to Build a Tip Calculator App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.

```