How to Build a BMI Calculator App in SwiftUI
A BMI Calculator app lets users track their Body Mass Index using height and weight inputs, with optional HealthKit sync for persistent health history. It's ideal for first-time iOS developers who want a real shipped product with a clear, measurable outcome.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge (variables, structs, views)
- A physical iPhone or iPad — HealthKit does not work on the Simulator
- Familiarity with Info.plist privacy keys (you'll add
NSHealthShareUsageDescriptionandNSHealthUpdateUsageDescription)
Architecture overview
The app uses a lightweight MVVM structure: a single @Observable BMIViewModel owns input state and drives the UI, a SwiftData BMIRecord model persists historical entries locally, and a HealthKitService class handles authorization and writes HKQuantitySample records for body mass and BMI. There are no network calls; everything runs on-device.
BMICalculator/ ├── BMICalculatorApp.swift # @main, ModelContainer setup ├── Models/ │ └── BMIRecord.swift # @Model — height, weight, bmi, date ├── ViewModels/ │ └── BMIViewModel.swift # @Observable — inputs, calculation, HealthKit ├── Views/ │ ├── ContentView.swift # Tab or NavigationStack root │ ├── InputView.swift # Height/weight fields + calculate button │ ├── ResultView.swift # BMI gauge + category badge │ └── HistoryView.swift # SwiftData list of past records ├── Services/ │ └── HealthKitService.swift # HKHealthStore wrapper └── PrivacyInfo.xcprivacy # Required for App Store
Step-by-step
1. Create the Xcode project and enable HealthKit
Create a new iOS App target in Xcode 16, set the minimum deployment to iOS 17, and add the HealthKit capability under Signing & Capabilities. Wire up the SwiftData ModelContainer in your app entry point so every view has access to the persistence layer.
// BMICalculatorApp.swift
import SwiftUI
import SwiftData
@main
struct BMICalculatorApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: BMIRecord.self)
}
}
After enabling HealthKit, open Info.plist and add two privacy keys:
NSHealthShareUsageDescription— "We read your health data to show BMI history."NSHealthUpdateUsageDescription— "We save your BMI to the Health app."
2. Define the SwiftData model
A simple @Model class stores each calculation result. Keeping height and weight alongside the BMI value lets you display a full history and recalculate if the formula ever changes.
// Models/BMIRecord.swift
import Foundation
import SwiftData
@Model
final class BMIRecord {
var date: Date
var heightCm: Double // always stored in metric internally
var weightKg: Double
var bmi: Double
init(heightCm: Double, weightKg: Double, bmi: Double, date: Date = .now) {
self.heightCm = heightCm
self.weightKg = weightKg
self.bmi = bmi
self.date = date
}
var category: String {
switch bmi {
case ..<18.5: return "Underweight"
case 18.5..<25: return "Normal weight"
case 25..<30: return "Overweight"
default: return "Obese"
}
}
var categoryColor: String {
switch bmi {
case ..<18.5: return "blue"
case 18.5..<25: return "green"
case 25..<30: return "orange"
default: return "red"
}
}
}
3. Build the input UI
The input view provides a unit toggle (metric/imperial), numeric text fields for height and weight, and a prominent calculate button. Keep validation inline so the button stays disabled until both fields have plausible values.
// Views/InputView.swift
import SwiftUI
struct InputView: View {
@Bindable var viewModel: BMIViewModel
var body: some View {
Form {
Section {
Picker("Unit system", selection: $viewModel.isMetric) {
Text("Metric (kg / cm)").tag(true)
Text("Imperial (lb / in)").tag(false)
}
.pickerStyle(.segmented)
}
Section(header: Text("Height")) {
HStack {
TextField(viewModel.isMetric ? "cm" : "inches", value: $viewModel.heightInput, format: .number)
.keyboardType(.decimalPad)
Text(viewModel.isMetric ? "cm" : "in")
.foregroundStyle(.secondary)
}
}
Section(header: Text("Weight")) {
HStack {
TextField(viewModel.isMetric ? "kg" : "lb", value: $viewModel.weightInput, format: .number)
.keyboardType(.decimalPad)
Text(viewModel.isMetric ? "kg" : "lb")
.foregroundStyle(.secondary)
}
}
Section {
Button(action: viewModel.calculate) {
Label("Calculate BMI", systemImage: "figure.stand")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.isInputValid)
}
}
.navigationTitle("BMI Calculator")
}
}
#Preview {
NavigationStack {
InputView(viewModel: BMIViewModel())
}
}
4. Implement BMI calculation and HealthKit sync
The @Observable view model owns all input state and calls the HealthKit service after saving the SwiftData record. Storing height and weight in metric internally simplifies the HealthKit writes, which also expect SI units.
// ViewModels/BMIViewModel.swift
import Foundation
import Observation
import SwiftData
@Observable
final class BMIViewModel {
var heightInput: Double? = nil
var weightInput: Double? = nil
var isMetric: Bool = true
var latestRecord: BMIRecord? = nil
var errorMessage: String? = nil
var isInputValid: Bool {
guard let h = heightInput, let w = weightInput else { return false }
return h > 0 && w > 0
}
// Convert inputs to metric, calculate, persist, sync
@MainActor
func calculate(context: ModelContext) {
guard let h = heightInput, let w = weightInput else { return }
let heightCm = isMetric ? h : h * 2.54
let weightKg = isMetric ? w : w * 0.453592
let heightM = heightCm / 100.0
let bmi = weightKg / (heightM * heightM)
let record = BMIRecord(heightCm: heightCm, weightKg: weightKg, bmi: bmi)
context.insert(record)
latestRecord = record
Task {
do {
try await HealthKitService.shared.saveBMI(
bmi: bmi, weightKg: weightKg, date: record.date
)
} catch {
errorMessage = error.localizedDescription
}
}
}
}
// Services/HealthKitService.swift
import HealthKit
final class HealthKitService {
static let shared = HealthKitService()
private let store = HKHealthStore()
private let writeTypes: Set<HKSampleType> = [
HKQuantityType(.bodyMassIndex),
HKQuantityType(.bodyMass)
]
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else { return }
try await store.requestAuthorization(toShare: writeTypes, read: [])
}
func saveBMI(bmi: Double, weightKg: Double, date: Date) async throws {
guard HKHealthStore.isHealthDataAvailable() else { return }
try await requestAuthorization()
let bmiType = HKQuantityType(.bodyMassIndex)
let weightType = HKQuantityType(.bodyMass)
let bmiSample = HKQuantitySample(
type: bmiType,
quantity: HKQuantity(unit: .count(), doubleValue: bmi),
start: date, end: date
)
let weightSample = HKQuantitySample(
type: weightType,
quantity: HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: weightKg),
start: date, end: date
)
try await store.save([bmiSample, weightSample])
}
}
Pass the SwiftData ModelContext from the view via the environment so calculate(context:) can call context.insert().
// Views/ContentView.swift
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
@State private var viewModel = BMIViewModel()
var body: some View {
NavigationStack {
InputView(viewModel: viewModel)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink {
HistoryView()
} label: {
Image(systemName: "clock.arrow.circlepath")
}
}
}
.sheet(item: $viewModel.latestRecord) { record in
ResultView(record: record)
}
.alert("HealthKit error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
.environment(viewModel)
.onTapGesture { hideKeyboard() }
}
private func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
// Views/ResultView.swift
import SwiftUI
struct ResultView: View {
let record: BMIRecord
var categoryColor: Color {
switch record.categoryColor {
case "blue": return .blue
case "green": return .green
case "orange": return .orange
default: return .red
}
}
var body: some View {
VStack(spacing: 24) {
Text("Your BMI")
.font(.headline)
.foregroundStyle(.secondary)
Text(String(format: "%.1f", record.bmi))
.font(.system(size: 72, weight: .bold, design: .rounded))
.foregroundStyle(categoryColor)
Text(record.category)
.font(.title3.weight(.semibold))
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(categoryColor.opacity(0.15))
.foregroundStyle(categoryColor)
.clipShape(Capsule())
Text("Saved to Health app")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(40)
.presentationDetents([.medium])
}
}
#Preview {
ResultView(record: BMIRecord(heightCm: 175, weightKg: 70, bmi: 22.9))
5. Add the Privacy Manifest
App Store review requires a PrivacyInfo.xcprivacy file for any app using HealthKit or certain system APIs. Without it, your binary will be rejected during automated validation. Add the file via File → New → File → App Privacy in Xcode.
<!-- PrivacyInfo.xcprivacy (Property List, XML format) -->
<?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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeHealth</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
If you integrate an ad SDK (Google Mobile Ads) in the next section, it will ship its own privacy manifest inside the framework — but double-check that it covers all required reason API categories your final binary touches.
Common pitfalls
- Testing HealthKit on the Simulator.
HKHealthStore.isHealthDataAvailable()returnsfalseon Simulator — your app won't crash, but nothing writes. Always test on a real device and gate writes with that check. - Requesting HealthKit authorization too early. Call
requestAuthorizationat the point of first use (when the user taps Calculate), not at app launch. Apple's App Store review team rejects apps that prompt for sensitive permissions before the user understands why. - Missing NSHealthUpdateUsageDescription in Info.plist. If you add HealthKit capability but omit one of the two required usage description keys, the app will crash on launch on device — not in review, but immediately on install.
- App Store rejection for misleading health claims. Guideline 1.4.1 flags apps that position BMI as a medical diagnostic tool. Keep your App Store description factual ("track BMI over time") and avoid words like "diagnosis," "clinically accurate," or "medical advice."
- Decimal separator locale bugs.
TextFieldwith.numberformat uses the device locale — users in many European countries enter "1,75" not "1.75". Using SwiftUI's built-inFormatStylehandles this correctly; don't parse the raw string withDouble(string).
Adding monetization: Ad-supported
The most practical ad integration for a utility app like this is Google Mobile Ads (AdMob), added via Swift Package Manager at https://github.com/googleads/swift-package-manager-google-mobile-ads. Add a banner ad below the calculate button using GADBannerView wrapped in a UIViewRepresentable. Register your app ID in Info.plist under GADApplicationIdentifier and use test ad unit IDs (ca-app-pub-3940256099942544/2934735716) during development — submit only real unit IDs to App Store review. AdMob ships its own privacy manifest, but you still need to present Apple's App Tracking Transparency prompt (ATTrackingManager.requestTrackingAuthorization) before the SDK initializes on iOS 14.5+ devices; skipping this is one of the most common rejection reasons for ad-supported apps. For a BMI calculator with typical retention, a banner on the result sheet and an interstitial after every fifth calculation is a reasonable balance between revenue and UX.
Shipping this faster with Soarias
Soarias automates the scaffolding steps that eat beginner time: project generation with the correct HealthKit entitlements and Info.plist keys already wired, PrivacyInfo.xcprivacy generation based on the frameworks you're using, fastlane Matchfile and Fastfile setup for certificate management, and the final App Store Connect submission including screenshots, age ratings, and privacy nutrition labels. Instead of spending a Saturday afternoon fighting code-signing errors, you run one command and get a clean repo ready for your first fastlane beta.
For a beginner-complexity app like this BMI Calculator, most developers spend 60–70% of their first shipping attempt on tooling rather than SwiftUI code. Soarias compresses that overhead to under 10 minutes — meaning a realistic first TestFlight build in an afternoon rather than a weekend, and a real App Store submission by end of day two instead of week two.
Related guides
FAQ
Does this work on iOS 16?
The SwiftData @Model macro requires iOS 17+. If you need iOS 16 support, replace SwiftData with a plain UserDefaults or Core Data store. The HealthKit and SwiftUI code in this guide is otherwise backward-compatible to iOS 16, but the #Preview macro requires Xcode 15+ to build regardless of deployment target.
Do I need a paid Apple Developer account to test?
You can install on your own device with a free personal team, but HealthKit requires a paid Apple Developer Program membership ($99/year) because it involves an entitlement Apple must provision. Without the paid account, Xcode will fail to sign the app when HealthKit capability is enabled.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then use the Organizer to upload to App Store Connect. Fill in the required metadata (name, subtitle, keywords, description, screenshots for 6.7" and 5.5" sizes), complete the privacy nutrition labels for Health data, and submit for review. First submissions for simple utility apps typically take 24–48 hours to review.
I'm a beginner — do I really need the HealthKit integration?
No. HealthKit is optional and adds complexity around authorization, real-device testing, and entitlements. If you want to ship your first app as quickly as possible, skip HealthKitService entirely — just save records to SwiftData locally. You can add HealthKit sync in a subsequent update once the app is live. The SwiftData model and UI code in steps 1–3 stand on their own without it.
Last reviewed: 2026-05-12 by the Soarias team.