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.
Prerequisites
- Mac with Xcode 16 or later installed
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI knowledge (know what a
View,State, andBindingare) - No third-party dependencies needed — Swift Charts ships with iOS 16+ and is available out of the box
- A real iPhone or iPad is not required; the iOS Simulator handles all features of this app
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
- Floating-point rounding on the final payment. The last month's balance rarely hits exactly zero due to double-precision rounding. Clamp it to zero with
max(0, balance)and adjust the final payment to clear the remainder, otherwise totals will look wrong. - Zero-rate crash. The annuity formula divides by
(factor - 1), which is zero when the rate is zero. Guard against a zero monthly rate and fall back toprincipal / termMonthsfor the no-interest case. - Charts import missing. Swift Charts is part of the SwiftUI framework family but requires
import Chartsexplicitly. Forgetting it produces a confusing "type 'Chart' has no member" build error. - App Store review rejection for missing Privacy Manifest. If you integrate any ad SDK, Apple's automated checks flag missing or incomplete privacy manifests before a human reviewer even sees your app. Always include
PrivacyInfo.xcprivacyand keep it up to date when you add SDKs. - Using
@ObservableObjectalongside@Observable. These are two different observation systems. Pick one per class — for iOS 17+ targets, use@Observablewith@Bindablein views and never mix inObservableObjectconformance on the same type.
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.