How to Build a Mortgage Calculator App in SwiftUI

A mortgage calculator app lets users enter a home price, down payment, interest rate, and loan term to instantly see their monthly payment and a full month-by-month amortization breakdown. It is a perfect beginner project: pure math, no network calls, real utility, and steady organic App Store downloads from homebuyers running their own numbers.

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

Prerequisites

Architecture overview

This app is classic MVVM with no persistence layer. A single MortgageViewModel marked @Observable owns all loan parameters as stored properties and exposes the monthly payment and the full amortization schedule as computed properties — so the math is always in sync with user input without any manual refresh. SwiftUI views read from the ViewModel reactively via the Observation framework. Swift Charts renders the principal-versus-interest payoff curve; PDFKit handles an optional schedule export sheet.

MortgageCalculator/
├── MortgageCalculatorApp.swift
├── Models/
│   └── AmortizationEntry.swift    # Identifiable per-month row
├── ViewModels/
│   └── MortgageViewModel.swift    # @Observable, pure math, no persistence
├── Views/
│   ├── ContentView.swift          # NavigationStack root + input form
│   ├── ScheduleListView.swift     # Chart + scrollable monthly table
│   └── SummaryCardView.swift      # Monthly payment callout
├── Export/
│   └── PDFExporter.swift          # PDFKit schedule-to-PDF helper
└── PrivacyInfo.xcprivacy          # Required App Store privacy manifest
        

Step-by-step

1. Set up the Xcode project

Open Xcode 16, choose File › New › Project, select the iOS App template, and name it MortgageCalculator. Set interface to SwiftUI and language to Swift. You do not need SwiftData or CloudKit — all state is computed on the fly from the user's inputs.

// MortgageCalculatorApp.swift
import SwiftUI

@main
struct MortgageCalculatorApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

// Deployment target: iOS 17.0
// Frameworks (all built-in, no SPM packages needed):
//   SwiftUI, Charts, PDFKit

2. Build the data model and ViewModel

Create an @Observable ViewModel that owns all four loan inputs and computes both the monthly payment (standard annuity formula) and the full amortization schedule as derived values. Using computed properties rather than stored state means you never manually invalidate stale data.

// Models/AmortizationEntry.swift
import Foundation

struct AmortizationEntry: Identifiable {
    let id = UUID()
    let month: Int
    let payment: Double
    let principalPaid: Double
    let interestPaid: Double
    let remainingBalance: Double
}

// ViewModels/MortgageViewModel.swift
import Observation

@Observable
final class MortgageViewModel {

    // MARK: - Inputs
    var homePrice: Double   = 500_000
    var downPayment: Double = 100_000
    var annualRate: Double  = 6.75   // percent, e.g. 6.75 means 6.75%
    var termYears: Int      = 30

    // MARK: - Derived

    var loanAmount: Double {
        max(homePrice - downPayment, 0)
    }

    var monthlyPayment: Double {
        guard loanAmount > 0, termYears > 0 else { return 0 }
        // 0% interest edge case: equal principal payments
        guard annualRate > 0 else {
            return loanAmount / Double(termYears * 12)
        }
        let r = annualRate / 100.0 / 12.0
        let n = Double(termYears * 12)
        return loanAmount * (r * pow(1 + r, n)) / (pow(1 + r, n) - 1)
    }

    var totalInterest: Double {
        max(monthlyPayment * Double(termYears * 12) - loanAmount, 0)
    }

    var schedule: [AmortizationEntry] {
        guard loanAmount > 0, termYears > 0 else { return [] }
        let r = annualRate > 0 ? annualRate / 100.0 / 12.0 : 0
        let payment = monthlyPayment
        var balance = loanAmount
        var entries: [AmortizationEntry] = []
        entries.reserveCapacity(termYears * 12)

        for month in 1...(termYears * 12) {
            let interest  = balance * r
            let principal = payment - interest
            balance      -= principal
            entries.append(AmortizationEntry(
                month: month,
                payment: payment,
                principalPaid: principal,
                interestPaid: interest,
                remainingBalance: max(0, balance)
            ))
        }
        return entries
    }
}

3. Build the input form

A Form with LabeledContent rows gives the right grouped look on iOS 17. Use the .currency and .number format styles so locale-aware formatting works automatically — no custom NumberFormatter subclass needed.

// Views/ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var vm = MortgageViewModel()

    private var currencyCode: String {
        Locale.current.currency?.identifier ?? "USD"
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Loan Details") {
                    LabeledContent("Home Price") {
                        TextField("e.g. 500000",
                                  value: $vm.homePrice,
                                  format: .currency(code: currencyCode))
                            .keyboardType(.decimalPad)
                            .multilineTextAlignment(.trailing)
                    }
                    LabeledContent("Down Payment") {
                        TextField("e.g. 100000",
                                  value: $vm.downPayment,
                                  format: .currency(code: currencyCode))
                            .keyboardType(.decimalPad)
                            .multilineTextAlignment(.trailing)
                    }
                    LabeledContent("Interest Rate (%)") {
                        TextField("e.g. 6.75",
                                  value: $vm.annualRate,
                                  format: .number.precision(.fractionLength(2)))
                            .keyboardType(.decimalPad)
                            .multilineTextAlignment(.trailing)
                    }
                    Picker("Loan Term", selection: $vm.termYears) {
                        ForEach([10, 15, 20, 25, 30], id: \.self) { years in
                            Text("\(years) years").tag(years)
                        }
                    }
                }

                Section("Summary") {
                    LabeledContent("Monthly Payment") {
                        Text(vm.monthlyPayment,
                             format: .currency(code: currencyCode))
                            .fontWeight(.bold)
                            .foregroundStyle(.blue)
                    }
                    LabeledContent("Total Interest") {
                        Text(vm.totalInterest,
                             format: .currency(code: currencyCode))
                            .foregroundStyle(.secondary)
                    }
                    LabeledContent("Total Cost") {
                        Text(vm.monthlyPayment * Double(vm.termYears * 12),
                             format: .currency(code: currencyCode))
                            .foregroundStyle(.secondary)
                    }
                }

                Section {
                    NavigationLink("View Amortization Schedule") {
                        ScheduleListView(vm: vm)
                    }
                }

                Section {
                    Text("Results are estimates only and do not constitute financial advice.")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            .navigationTitle("Mortgage Calculator")
        }
    }
}

#Preview {
    ContentView()
}

4. Display the amortization schedule with Swift Charts

The schedule view combines a stacked AreaMark chart — showing how principal gradually overtakes interest each month — with a scrollable List of every row. A ShareLink in the toolbar exports the schedule as a CSV, keeping the feature lean while still being genuinely useful.

// Views/ScheduleListView.swift
import SwiftUI
import Charts

struct ScheduleListView: View {
    let vm: MortgageViewModel

    var body: some View {
        List {
            Section {
                Chart(vm.schedule) { entry in
                    AreaMark(
                        x: .value("Month", entry.month),
                        y: .value("Principal", entry.principalPaid)
                    )
                    .foregroundStyle(.blue.opacity(0.75))

                    AreaMark(
                        x: .value("Month", entry.month),
                        y: .value("Interest", entry.interestPaid)
                    )
                    .foregroundStyle(.orange.opacity(0.65))
                }
                .frame(height: 180)
                .chartXAxisLabel("Month")
                .chartYAxisLabel("Payment")
                .chartLegend(position: .topTrailing)
                .padding(.vertical, 8)
            } header: {
                Text("Principal vs Interest Over Time")
            }

            Section("Monthly Breakdown") {
                ForEach(vm.schedule) { entry in
                    HStack(spacing: 8) {
                        Text("Month \(entry.month)")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .frame(width: 68, alignment: .leading)
                        Spacer()
                        VStack(alignment: .trailing, spacing: 2) {
                            Text(entry.principalPaid,
                                 format: .currency(code: "USD").precision(.fractionLength(0)))
                                .font(.caption2)
                                .foregroundStyle(.blue)
                            Text(entry.interestPaid,
                                 format: .currency(code: "USD").precision(.fractionLength(0)))
                                .font(.caption2)
                                .foregroundStyle(.orange)
                        }
                        Text(entry.remainingBalance,
                             format: .currency(code: "USD").precision(.fractionLength(0)))
                            .font(.caption)
                            .frame(width: 88, alignment: .trailing)
                    }
                }
            }
        }
        .navigationTitle("Amortization Schedule")
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                ShareLink(
                    item: csvString(),
                    subject: Text("Amortization Schedule"),
                    message: Text("Your mortgage breakdown")
                ) {
                    Label("Export CSV", systemImage: "square.and.arrow.up")
                }
            }
        }
    }

    private func csvString() -> String {
        var csv = "Month,Payment,Principal,Interest,Balance\n"
        for e in vm.schedule {
            csv += "\(e.month),"
                + String(format: "%.2f", e.payment) + ","
                + String(format: "%.2f", e.principalPaid) + ","
                + String(format: "%.2f", e.interestPaid) + ","
                + String(format: "%.2f", e.remainingBalance) + "\n"
        }
        return csv
    }
}

#Preview {
    NavigationStack {
        ScheduleListView(vm: MortgageViewModel())
    }
}

5. Add the Privacy Manifest

Apple requires a PrivacyInfo.xcprivacy file in every App Store submission. A mortgage calculator collects no user data, but the file must still be present and correctly formed — App Store Connect rejects builds silently at upload if it is missing.

<!-- PrivacyInfo.xcprivacy -->
<?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 user data collected -->
    <key>NSPrivacyCollectedDataTypes</key>
    <array/>

    <!-- App does not track users -->
    <key>NSPrivacyTracking</key>
    <false/>

    <!-- No tracking domains -->
    <key>NSPrivacyTrackingDomains</key>
    <array/>

    <!-- No required-reason APIs used -->
    <key>NSPrivacyAccessedAPITypes</key>
    <array/>
</dict>
</plist>

<!-- How to add in Xcode 16:
     File > New > File from Template > App Privacy
     Xcode creates PrivacyInfo.xcprivacy and adds it to
     your target automatically. Use the GUI editor to
     confirm all four fields above are set correctly.  -->

Common pitfalls

Adding monetization: One-time purchase

A one-time in-app purchase via StoreKit 2 is the natural model here — no subscription fatigue, no recurring billing to explain, and a low price point ($1.99–$3.99) converts well for a utility. Gate the amortization schedule and CSV export behind a single non-consumable Product (e.g. "Unlock Full Schedule") that you create in App Store Connect. On app launch call Product.products(for:["com.yourapp.unlock"]) to fetch the product, product.purchase() to start the payment sheet, then verify entitlement with Transaction.currentEntitlement(for:). StoreKit 2's async/await API keeps the entire purchase flow under 30 lines. Always call Transaction.finish() after granting access — otherwise StoreKit re-queues the transaction on every launch.

Shipping this faster with Soarias

Soarias automates the four steps that chew up the most time between "code done" and "live on the App Store": it generates the full Xcode project scaffold (including a correctly-formed PrivacyInfo.xcprivacy file), configures fastlane match for certificate and profile management, populates all required App Store Connect metadata fields (description, keywords, support URL, copyright, age rating), and runs deliver to upload the binary and submit for review. For this specific app you can prompt Soarias with the screens described above and it will scaffold the complete file tree shown in the architecture section, including the export helper.

A beginner building a mortgage calculator for the first time typically spends three to five hours on provisioning profiles, screenshot generation, and App Store Connect form-filling — work that has nothing to do with the amortization logic. With Soarias that overhead collapses to under thirty minutes: you stay in your editor writing the math; Soarias handles everything from code signing to the "Waiting for Review" status notification.

Related guides

FAQ

Does this work on iOS 16?

The @Observable macro and the #Preview macro both require iOS 17. If you need iOS 16 support, replace @Observable with ObservableObject and @Published, swap @State private var vm for @StateObject private var vm, and use PreviewProvider instead of #Preview. Swift Charts shipped in iOS 16, so the chart code compiles on iOS 16 with those substitutions.

Do I need a paid Apple Developer account to test?

No. You can sideload the app onto your personal iPhone using a free personal team in Xcode — it will be valid for 7 days before needing to be re-signed. The $99/year Apple Developer Program membership is only required when you want to distribute via TestFlight, enable push notifications, or publish to the App Store.

How do I add this to the App Store?

Archive the app in Xcode via Product › Archive, then open the Organizer, validate the archive, and distribute it to App Store Connect. You will need at minimum a 6.9-inch (iPhone 16 Pro Max) and 6.5-inch screenshot set, a support URL, a privacy policy URL, and the PrivacyInfo.xcprivacy file in your target. Soarias automates all metadata fields and screenshot upload via fastlane deliver.

Can I add PDF export for the amortization schedule?

Yes, and it is simpler than it looks. PDFKit ships with iOS, so there is no extra dependency. Create a UIGraphicsPDFRenderer, loop through vm.schedule drawing rows with NSAttributedString, write the resulting Data to a temporary file, and hand the URL to a ShareLink. Expect 60–80 lines of code for a clean, tabular PDF. Gate it behind your one-time StoreKit purchase to add a clear upgrade incentive.

Last reviewed: 2026-05-12 by the Soarias team.