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

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.

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

Prerequisites

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

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.

```