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

How to Build a Loan Calculator App in SwiftUI

A Loan Calculator app lets users enter a principal amount, interest rate, and loan term, then instantly see monthly payments and a full amortization schedule broken down into principal and interest. It's ideal for first-time iOS developers who want a practical, math-driven project that looks great with Swift Charts.

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

Prerequisites

Architecture overview

This app follows a lightweight MVVM structure. The LoanParameters value type holds the user's inputs, and a pure AmortizationEngine struct converts those parameters into a schedule of PaymentRow values — no networking, no server, no side effects. SwiftData persists saved loan scenarios. The LoanFormView drives inputs, ScheduleView shows the table, and ChartView renders the principal-vs-interest breakdown using Swift Charts. State flows down via @Bindable on an @Observable view model.

LoanCalculatorApp/
├── LoanCalculatorApp.swift       # @main entry, .modelContainer
├── Models/
│   ├── LoanParameters.swift      # struct: principal, rate, termMonths
│   ├── PaymentRow.swift          # struct: month, payment, principal, interest, balance
│   └── SavedLoan.swift           # @Model SwiftData entity
├── Engine/
│   └── AmortizationEngine.swift  # pure func schedule(for:) -> [PaymentRow]
├── ViewModels/
│   └── LoanViewModel.swift       # @Observable, owns LoanParameters + schedule
├── Views/
│   ├── ContentView.swift         # NavigationSplitView root
│   ├── LoanFormView.swift        # Sliders + text fields
│   ├── ScheduleView.swift        # List of PaymentRow
│   └── LoanChartView.swift       # Swift Charts bar chart
└── PrivacyInfo.xcprivacy         # Required for App Store
        

Step-by-step

1. Create the Xcode project

Open Xcode 16, choose File › New › Project, pick the App template under iOS, set the interface to SwiftUI, and tick Use SwiftData. Name it LoanCalculator. Delete the placeholder Item model Xcode generates — you'll replace it with your own.

// LoanCalculatorApp.swift
import SwiftUI
import SwiftData

@main
struct LoanCalculatorApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: SavedLoan.self)
    }
}

2. Define the data model

Create three small types: a plain struct for live inputs, a struct for each payment row in the schedule, and a SwiftData @Model class to persist saved scenarios between launches.

// Models/LoanParameters.swift
struct LoanParameters {
    var principal: Double = 250_000
    var annualRate: Double = 6.5     // percent, e.g. 6.5 = 6.5%
    var termMonths: Int = 360        // 30 years
}

// Models/PaymentRow.swift
struct PaymentRow: Identifiable {
    let id: Int            // month number (1-based)
    let payment: Double
    let principalPaid: Double
    let interestPaid: Double
    let balance: Double
}

// Models/SavedLoan.swift
import SwiftData

@Model
final class SavedLoan {
    var name: String
    var principal: Double
    var annualRate: Double
    var termMonths: Int
    var createdAt: Date

    init(name: String, params: LoanParameters) {
        self.name = name
        self.principal = params.principal
        self.annualRate = params.annualRate
        self.termMonths = params.termMonths
        self.createdAt = .now
    }
}

3. Build the input form

The form needs three controls: a currency text field for the principal, a slider for the interest rate, and a stepper for the loan term. Using @Bindable on the view model keeps the syntax clean in iOS 17+.

// Views/LoanFormView.swift
import SwiftUI

struct LoanFormView: View {
    @Bindable var vm: LoanViewModel

    var body: some View {
        Form {
            Section("Loan amount") {
                HStack {
                    Text("$")
                    TextField("Principal", value: $vm.params.principal,
                              format: .number.precision(.fractionLength(0)))
                        .keyboardType(.decimalPad)
                }
            }

            Section("Annual interest rate: \(vm.params.annualRate, specifier: "%.2f")%") {
                Slider(value: $vm.params.annualRate, in: 0.5...25, step: 0.05)
            }

            Section("Loan term") {
                Stepper("\(vm.params.termMonths / 12) years (\(vm.params.termMonths) months)",
                        value: $vm.params.termMonths,
                        in: 12...360, step: 12)
            }

            Section("Summary") {
                LabeledContent("Monthly payment",
                    value: vm.monthlyPayment,
                    format: .currency(code: "USD"))
                LabeledContent("Total interest",
                    value: vm.totalInterest,
                    format: .currency(code: "USD"))
                LabeledContent("Total cost",
                    value: vm.totalCost,
                    format: .currency(code: "USD"))
            }
        }
        .navigationTitle("Loan Calculator")
    }
}

#Preview {
    NavigationStack {
        LoanFormView(vm: LoanViewModel())
    }
}

4. Implement interest and payment schedules with Charts

This is the core feature. The AmortizationEngine applies the standard annuity formula to generate a row for every month of the loan. The view model exposes the schedule, and a Swift Charts bar chart makes the principal/interest breakdown immediately legible.

// Engine/AmortizationEngine.swift
import Foundation

struct AmortizationEngine {
    static func schedule(for params: LoanParameters) -> [PaymentRow] {
        guard params.annualRate > 0, params.termMonths > 0,
              params.principal > 0 else { return [] }

        let monthlyRate = (params.annualRate / 100) / 12
        let n = Double(params.termMonths)
        // Standard annuity formula
        let factor = pow(1 + monthlyRate, n)
        let payment = params.principal * (monthlyRate * factor) / (factor - 1)

        var rows: [PaymentRow] = []
        var balance = params.principal

        for month in 1...params.termMonths {
            let interestPaid = balance * monthlyRate
            let principalPaid = payment - interestPaid
            balance -= principalPaid
            rows.append(PaymentRow(
                id: month,
                payment: payment,
                principalPaid: principalPaid,
                interestPaid: interestPaid,
                balance: max(0, balance)
            ))
        }
        return rows
    }
}

// ViewModels/LoanViewModel.swift
import Observation

@Observable
final class LoanViewModel {
    var params = LoanParameters()

    var schedule: [PaymentRow] {
        AmortizationEngine.schedule(for: params)
    }
    var monthlyPayment: Double { schedule.first?.payment ?? 0 }
    var totalInterest: Double { schedule.reduce(0) { $0 + $1.interestPaid } }
    var totalCost: Double { params.principal + totalInterest }
}

// Views/LoanChartView.swift
import SwiftUI
import Charts

struct LoanChartView: View {
    let schedule: [PaymentRow]

    // Sample every 12th row so the chart stays readable
    private var annual: [PaymentRow] {
        stride(from: 0, to: schedule.count, by: 12).compactMap {
            schedule.indices.contains($0) ? schedule[$0] : nil
        }
    }

    var body: some View {
        Chart {
            ForEach(annual) { row in
                BarMark(
                    x: .value("Year", row.id / 12),
                    y: .value("Principal", row.principalPaid * 12)
                )
                .foregroundStyle(by: .value("Type", "Principal"))

                BarMark(
                    x: .value("Year", row.id / 12),
                    y: .value("Interest", row.interestPaid * 12)
                )
                .foregroundStyle(by: .value("Type", "Interest"))
            }
        }
        .chartForegroundStyleScale([
            "Principal": Color.blue,
            "Interest": Color.orange
        ])
        .chartXAxisLabel("Year")
        .chartYAxisLabel("Annual payment ($)")
        .frame(height: 240)
        .padding()
    }
}

#Preview {
    LoanChartView(schedule: AmortizationEngine.schedule(for: LoanParameters()))
}

// Views/ScheduleView.swift
import SwiftUI

struct ScheduleView: View {
    let schedule: [PaymentRow]

    var body: some View {
        List(schedule) { row in
            HStack {
                Text("Mo. \(row.id)")
                    .font(.caption.monospacedDigit())
                    .frame(width: 56, alignment: .leading)
                Spacer()
                VStack(alignment: .trailing, spacing: 2) {
                    Text(row.principalPaid, format: .currency(code: "USD"))
                        .font(.caption.monospacedDigit())
                        .foregroundStyle(.blue)
                    Text(row.interestPaid, format: .currency(code: "USD"))
                        .font(.caption.monospacedDigit())
                        .foregroundStyle(.orange)
                }
                Text(row.balance, format: .currency(code: "USD"))
                    .font(.caption.monospacedDigit())
                    .frame(width: 90, alignment: .trailing)
                    .foregroundStyle(.secondary)
            }
        }
        .navigationTitle("Payment Schedule")
    }
}

5. Add the Privacy Manifest

Apple requires a PrivacyInfo.xcprivacy file for every App Store submission. A loan calculator collects no personal data, so the manifest is minimal — but omitting it will cause an automatic rejection during processing.

<!-- 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>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>   <!-- no data collected -->

  <key>NSPrivacyAccessedAPITypes</key>
  <array/>   <!-- no required-reason APIs used -->

  <key>NSPrivacyTracking</key>
  <false/>

  <key>NSPrivacyTrackingDomains</key>
  <array/>
</dict>
</plist>

<!-- NOTE: If you integrate an ad SDK (Google AdMob, etc.)
     the SDK's own privacy manifest merges automatically via
     the SPM package, but you must declare any data it collects
     in your app's manifest as well if it's linked to the user. -->

Common pitfalls

Adding monetization: Ad-supported

The most practical ad placement for a calculator app is a banner anchored to the bottom of the main form view, so it never obscures inputs or results. Google AdMob is the dominant iOS banner SDK — add it via Swift Package Manager (https://github.com/googleads/swift-package-manager-google-mobile-ads), initialise GADMobileAds.sharedInstance().start() in your App body, then wrap GADBannerView in a UIViewRepresentable and attach it to the bottom of ContentView. Because AdMob uses Apple's required-reason APIs and collects device identifiers, you must also add AdMob's own Privacy Manifest entries to yours and include an NSUserTrackingUsageDescription in Info.plist — App Tracking Transparency consent is required for personalised ads on iOS 14+. A well-placed banner on a utility app with strong organic ASO typically yields eCPMs in the $2–$6 range, making the ad-supported model viable for a free tool with daily active users.

Shipping this faster with Soarias

Soarias handles all the friction that sits between finished code and a live App Store listing. For a Loan Calculator, that means auto-generating the Xcode project scaffold with correct bundle IDs, capabilities, and SwiftData entitlements; pre-filling the PrivacyInfo.xcprivacy based on the SDKs you've linked; setting up fastlane match for code signing; and driving fastlane deliver to push your binary, metadata, and screenshots to App Store Connect in one command — no manual form-filling in the ASC web UI.

For a beginner-complexity app like this, the manual path to a first TestFlight build typically takes most of a weekend just on Xcode setup, provisioning profiles, and ASC configuration. With Soarias ($79, one-time), that overhead collapses to under an hour: you answer a short discovery questionnaire, Soarias scaffolds the project and wires the signing pipeline, and you spend your time on the actual loan logic and chart polish instead of certificate headaches.

Related guides

FAQ

Does this work on iOS 16?

Swift Charts and @Observable both require iOS 17+. If you need iOS 16 support, replace @Observable with ObservableObject / @Published and Swift Charts still works (it was introduced in iOS 16), but you lose the cleaner observation syntax. The #Preview macro requires Xcode 15+ regardless of deployment target.

Do I need a paid Apple Developer account to test on a device?

No — Xcode can sideload apps to a personally registered device using a free Apple ID. However, the free tier limits you to three apps at a time and does not allow TestFlight distribution or App Store submission. You need the $99/year Apple Developer Program for either of those.

How do I submit this to the App Store?

Archive your app in Xcode (Product › Archive), then use the Organizer to upload to App Store Connect. In ASC, create an app record, fill in the metadata and screenshots, attach the build, and submit for review. Alternatively, fastlane deliver (which Soarias wires up for you) automates the entire upload and metadata step from the command line.

Is a Loan Calculator too simple to make money on the App Store?

Not at all — utility apps with strong keyword ASO ("mortgage calculator", "amortization schedule") attract high-intent searches from users who are actively planning a major financial decision. A clean, fast, offline-first calculator with no account required stands out against bloated fintech apps. The ad-supported model works well here because users return repeatedly as rates and scenarios change.

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

```