← All tech

Mobile / UI

SwiftUI

Native, fluid Apple-platform UI from declarative Swift, with deep OS integration.

  • Declarative UI that stays in sync with state automatically
  • Native look and feel across iPhone, iPad, Mac, Watch and TV
  • Deep OS integration — HealthKit, widgets, App Intents, Live Activities
  • Live Xcode previews that render as you type
Use it when

Reach for SwiftUI when you are building for Apple platforms and want the fastest path to a native, accessible, animation-rich UI that the system keeps in sync with your state — and when tight integration with iOS frameworks (HealthKit, WidgetKit, StoreKit) matters.

Reach for something else when

Skip SwiftUI when you must ship the same app to Android from one codebase (use Flutter or KMP), when your minimum deployment target is below iOS 17 since the modern Observation APIs this track teaches need iOS 17, or when a screen needs UIKit-only controls SwiftUI has not yet wrapped.

Official docs ↗


SwiftUI is the mobile track for shipping native Apple-platform apps with the least UI plumbing: you describe what the screen should look like for a given state, and the framework keeps the pixels in sync as that state changes. This track moves from your first View to the modern Observation framework, navigation, Swift concurrency, and OS integration like HealthKit — tagged by level so you read only as deep as you need.

Make your first View with a body

Beginner

Create a new SwiftUI app in Xcode and replace the body of ContentView with a Text. Run the live preview.

What a View actually is

In SwiftUI a screen is a value, not an object you mutate. View is a protocol with one requirement: a computed body property that returns more views. You compose small views into bigger ones; SwiftUI diffs the description and updates only what changed. There is no viewDidLoad, no manual layout pass — the body is the UI for the current state.

The smallest View
Run these in your terminal / editor
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, Vitals")
            .font(.title)
            .padding()
    }
}

#Preview {
    ContentView()
}

Lay out content with stacks

Beginner

Arrange views vertically with VStack, horizontally with HStack, and layer them with ZStack.

Stacks, spacing, and alignment

Stacks are the bread-and-butter layout containers. VStack stacks children top-to-bottom, HStack left-to-right, and ZStack back-to-front. Each takes an alignment and spacing. Modifiers like .padding(), .frame(), and .background() apply in order — the order matters, because each modifier wraps the view it follows. Reach for Spacer() to push content apart and fill available space.

A small card layout
Run these in your terminal / editor
struct StepCard: View {
    let title: String
    let value: String

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(title)
                    .font(.headline)
                Text(value)
                    .font(.largeTitle.bold())
            }
            Spacer()
            Image(systemName: "figure.walk")
                .font(.largeTitle)
        }
        .padding()
        .background(.regularMaterial, in: .rect(cornerRadius: 16))
    }
}

Make the UI react to @State

Beginner

Add a @State property and a Button that changes it. Watch the view update with no manual refresh.

State is the single source of truth

@State marks a small piece of value-type data that a view owns. When you mutate it, SwiftUI re-invokes body and updates the affected pixels — you never call a “reload” function. Keep @State private to the view; it is for local, view-owned data like a toggle or a text field’s contents. Larger or shared data belongs in an observable model (next steps).

A counter that re-renders itself
Run these in your terminal / editor
struct GlassCounter: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("\(count) glasses of water")
                .font(.title2)
            Button("Add a glass") {
                count += 1
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior iOS engineer working in this SwiftUI app.
Context: A fresh Xcode SwiftUI app (iOS 17+). Views live in the app target; previews use the #Preview macro.
Task: Add a WaterTrackerView with a glass counter backed by @State, plus an Xcode preview.
Requirements:
- A private @State var count starts at 0; an "Add a glass" button increments it, a "Reset" button sets it to 0.
- count is clamped at 0 and never goes negative.
- The current count is shown as "N glasses".
- Provide a #Preview that renders WaterTrackerView().
Tests / acceptance:
- Add a SwiftUI unit/UI test: tapping "Add a glass" twice then "Reset" leaves the label reading "0 glasses".
- The Xcode preview builds and renders without errors.
Output: a unified diff plus a one-paragraph note on why count lives in @State and not an observable model.

Show a list of data with List and ForEach

Beginner

Model your data as an Identifiable struct and render it with List and ForEach.

Why Identifiable matters

List builds a scrollable, platform-native table. To diff rows efficiently SwiftUI needs a stable identity for each item, so your model conforms to Identifiable (a single id property). ForEach then iterates the collection and produces a row view per element. Because identity is stable, insertions and deletions animate correctly out of the box.

A list of readings
Run these in your terminal / editor
struct Reading: Identifiable {
    let id = UUID()
    let label: String
    let value: Int
}

struct ReadingsList: View {
    let readings: [Reading]

    var body: some View {
        List(readings) { reading in
            HStack {
                Text(reading.label)
                Spacer()
                Text("\(reading.value)")
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Model shared state with @Observable

Intermediate

Make a model class @Observable, store it once with @State, and read it in child views. This is the modern Observation API (iOS 17+).

@Observable vs the old ObservableObject

Before iOS 17 you wrote class Model: ObservableObject and marked every reactive field @Published, then observed it with @StateObject / @ObservedObject. The Observation framework replaces all of that: annotate the class with the @Observable macro and SwiftUI tracks exactly the properties a view reads — no @Published, fewer needless re-renders. You own the instance with @State at the top of the hierarchy and read it directly in children; pass it down explicitly or via the environment. ObservableObject still works for pre-iOS-17 targets, but @Observable is the default for new code.

An observable model driving the UI
Run these in your terminal / editor
import Observation
import SwiftUI

@Observable
final class HydrationModel {
    var glasses = 0
    var goal = 8

    var isGoalMet: Bool { glasses >= goal }

    func drink() { glasses += 1 }
}

struct HydrationView: View {
    @State private var model = HydrationModel()

    var body: some View {
        VStack(spacing: 12) {
            Text("\(model.glasses) / \(model.goal)")
                .font(.largeTitle.bold())
            if model.isGoalMet {
                Text("Goal met")
                    .foregroundStyle(.green)
            }
            Button("Drink") { model.drink() }
                .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior iOS engineer in this SwiftUI app, iOS 17+.
Context: We use the Observation framework (the @Observable macro), not ObservableObject/@Published.
Task: Implement HydrationModel as an @Observable class and a HydrationView that observes it via @State.
Requirements:
- @Observable final class HydrationModel with var glasses, var goal (default 8), a computed isGoalMet, and drink().
- HydrationView owns the model with @State private var model = HydrationModel().
- Do NOT use ObservableObject, @Published, @StateObject, or @ObservedObject anywhere.
- A child SummaryView receives the model as a plain parameter and shows "glasses/goal".
Tests / acceptance:
- A unit test constructs HydrationModel, calls drink() eight times, and asserts isGoalMet == true.
- `import Observation` is present; grep shows no @Published in the diff.
Output: a unified diff plus a one-paragraph note on why @Observable re-renders less than @Published.

Share a binding with @Binding

Intermediate

Pass a @State value into a child view as a @Binding so the child can read and write the parent’s state.

One source of truth, two-way reads

@State owns the value; @Binding is a reference to someone else’s state. When a child needs to mutate data the parent owns — a toggle, a slider, an editable text field — you pass $value (the projected binding) into a @Binding property. Both views then see one source of truth: a write through the binding updates the parent and re-renders everyone reading it. This keeps state in exactly one place instead of copying and re-syncing it.

A reusable stepper child
Run these in your terminal / editor
struct GoalStepper: View {
    @Binding var goal: Int

    var body: some View {
        Stepper("Daily goal: \(goal)", value: $goal, in: 1...20)
    }
}

struct SettingsView: View {
    @State private var goal = 8

    var body: some View {
        Form {
            GoalStepper(goal: $goal)        // pass the binding with $
            Text("You'll aim for \(goal) glasses")
        }
    }
}

Pass dependencies through @Environment

Intermediate

Put shared services or values into the SwiftUI environment and read them with @Environment deep in the tree.

Avoid threading models through every initialiser

The environment is SwiftUI’s dependency-injection channel. Inject a value once at a high level with .environment(...) and any descendant reads it with @Environment — no need to pass it through every intervening view’s initialiser. It also exposes system values like \.colorScheme, \.dismiss, and \.locale. For an @Observable model you want app-wide, injecting it into the environment is the idiomatic way to share one instance.

Inject and read an observable model
Run these in your terminal / editor
@main
struct VitalsApp: App {
    @State private var model = HydrationModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(model)        // inject the instance
        }
    }
}

struct ContentView: View {
    @Environment(HydrationModel.self) private var model   // read it anywhere below

    var body: some View {
        Text("\(model.glasses) glasses today")
    }
}

Load data with async/await on the main actor

Advanced

Use the .task modifier to run async work when a view appears, and keep UI updates on the main actor.

Structured concurrency, wired into the view lifecycle

SwiftUI integrates with Swift concurrency directly. The .task { } modifier starts an async job when the view appears and cancels it automatically when the view disappears — no manual lifecycle bookkeeping. Mark UI-touching types @MainActor (an @Observable view model usually is) so mutations that drive body happen on the main thread; await suspends without blocking it. This replaces completion-handler callbacks and the old onAppear + manual Task dance.

Fetch on appear, update on the main actor
Run these in your terminal / editor
@MainActor
@Observable
final class FeedModel {
    var readings: [Reading] = []
    var isLoading = false

    func load() async {
        isLoading = true
        defer { isLoading = false }
        readings = await VitalsService.fetchReadings()   // async call
    }
}

struct FeedView: View {
    @State private var model = FeedModel()

    var body: some View {
        List(model.readings) { Text($0.label) }
            .overlay { if model.isLoading { ProgressView() } }
            .task { await model.load() }   // runs on appear, cancels on disappear
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior iOS engineer in this SwiftUI app, iOS 17+, Swift concurrency enabled.
Context: VitalsService has `static func fetchReadings() async throws -> [Reading]`. UI uses @Observable models.
Task: Build a FeedModel and FeedView that load readings asynchronously via the .task modifier.
Requirements:
- FeedModel is @MainActor and @Observable, with readings, isLoading, and an errorMessage: String?.
- load() is async, sets isLoading around the call, and catches errors into errorMessage instead of crashing.
- FeedView calls .task { await model.load() }; show a ProgressView while loading and the error text on failure.
- No DispatchQueue.main.async and no completion handlers — use async/await only.
Tests / acceptance:
- A test injects a stub service that throws; assert errorMessage is set and readings stays empty.
- `grep` of the diff shows no DispatchQueue.main.async.
Output: a unified diff plus a one-paragraph note on how .task ties cancellation to the view lifecycle.

Read real Health data with HealthKit

Advanced

Request HealthKit authorization, then query the user’s step count. This is where native beats cross-platform.

Why this is SwiftUI's home turf

HealthKit is a first-party Apple framework with no cross-platform equivalent — accessing it is a core reason to build natively. You add the HealthKit capability, declare a usage-description key in Info.plist, then call HKHealthStore.requestAuthorization and run an HKStatisticsQuery for a quantity type like .stepCount. Authorization is per-type and user-granted; always handle the denied case gracefully. Feed the result into an @Observable @MainActor model so the SwiftUI view updates as data arrives.

Authorize and read today's steps
Run these in your terminal / editor
import HealthKit

let store = HKHealthStore()

func requestSteps() async throws {
    guard HKHealthStore.isHealthDataAvailable() else { return }
    let steps = HKQuantityType(.stepCount)
    try await store.requestAuthorization(toShare: [], read: [steps])
}

func todaysSteps() async throws -> Double {
    let type = HKQuantityType(.stepCount)
    let startOfDay = Calendar.current.startOfDay(for: .now)
    let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: .now)

    return try await withCheckedThrowingContinuation { continuation in
        let query = HKStatisticsQuery(
            quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum
        ) { _, stats, error in
            if let error { continuation.resume(throwing: error); return }
            let count = stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0
            continuation.resume(returning: count)
        }
        store.execute(query)
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior iOS engineer in this SwiftUI app, iOS 17+.
Context: The app has the HealthKit capability enabled and NSHealthShareUsageDescription set in Info.plist.
Task: Add a HealthStore wrapper exposing async requestAuthorization() and todaysStepCount() -> Double, plus a StepsView.
Requirements:
- Guard with HKHealthStore.isHealthDataAvailable(); if false, surface "Health data unavailable" and do not crash.
- Use HKQuantityType(.stepCount) and an HKStatisticsQuery with .cumulativeSum, wrapped via withCheckedThrowingContinuation.
- The model is @MainActor + @Observable; StepsView loads via .task and shows the count or a denied/unavailable message.
- Read-only (toShare: []); never force-unwrap the optional quantity.
Tests / acceptance:
- A test injects a protocol-based fake store returning 4200 and asserts the view model exposes 4200 steps.
- On a simulator without Health data, the view shows the unavailable message rather than crashing (manual check).
Output: a unified diff plus a one-paragraph note on why HealthKit access is per-type and user-granted.

Add a Home Screen widget with WidgetKit

Advanced

Create a Widget Extension that renders a SwiftUI view on a TimelineProvider schedule.

The same View code, on the Home Screen

Widgets are a separate app extension target, but the UI is the same SwiftUI you already write — a View inside a Widget’s StaticConfiguration. WidgetKit drives them with a TimelineProvider: you hand back a series of timed entries, and the system renders and refreshes the widget for you (you do not run continuously). Widgets are deliberately constrained — no scrolling, a fixed refresh budget — which is why the declarative, state-as-snapshot model fits them so well.

A minimal static widget
Run these in your terminal / editor
import WidgetKit
import SwiftUI

struct StepsEntry: TimelineEntry {
    let date: Date
    let steps: Int
}

struct StepsProvider: TimelineProvider {
    func placeholder(in context: Context) -> StepsEntry { .init(date: .now, steps: 0) }

    func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {
        completion(.init(date: .now, steps: 4200))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
        let entry = StepsEntry(date: .now, steps: 4200)
        completion(Timeline(entries: [entry], policy: .atEnd))
    }
}

struct StepsWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "StepsWidget", provider: StepsProvider()) { entry in
            VStack {
                Text("\(entry.steps)").font(.largeTitle.bold())
                Text("steps").font(.caption)
            }
            .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Steps")
        .supportedFamilies([.systemSmall])
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior iOS engineer in this app, iOS 17+, with a Widget Extension target already added.
Context: WidgetKit is available; the widget shares a step count with the app via an App Group / shared store.
Task: Implement a StepsWidget that shows the latest step count in a systemSmall family.
Requirements:
- A StepsEntry: TimelineEntry with date and steps.
- A TimelineProvider implementing placeholder, getSnapshot, and getTimeline with a sensible refresh policy (.atEnd or .after).
- The widget view uses .containerBackground(...) for: .widget (required on iOS 17).
- Read the step count from the shared store; fall back to 0 if unavailable (no force-unwrap).
Tests / acceptance:
- The widget target builds; the widget preview renders a systemSmall layout with a number.
- A unit test on the provider asserts getTimeline returns at least one entry with the expected step count.
Output: a unified diff plus a one-paragraph note on why widgets use a TimelineProvider instead of running continuously.

Animate state changes and polish previews

Advanced

Wrap a state change in withAnimation and use .animation(_:value:) for implicit transitions. Add multiple #Previews to check states fast.

Animation is a function of state, not a timeline

In SwiftUI you don’t script frames; you change state inside withAnimation { } and the framework interpolates the affected views from old to new. .animation(_:value:) ties an implicit animation to a specific value’s changes. Because the UI is a pure function of state, Xcode previews can render every state instantly — add several #Previews (empty, loading, full, dark mode) to review them side by side without launching the app, which is one of SwiftUI’s biggest day-to-day productivity wins.

Animated reveal plus state previews
Run these in your terminal / editor
struct GoalBadge: View {
    @State private var met = false

    var body: some View {
        VStack {
            if met {
                Text("Goal met")
                    .transition(.scale.combined(with: .opacity))
            }
            Button("Toggle") {
                withAnimation(.spring) { met.toggle() }
            }
        }
        .animation(.default, value: met)
    }
}

#Preview("Default") { GoalBadge() }

#Preview("Dark") {
    GoalBadge().preferredColorScheme(.dark)
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: SwiftUI teacher. The reader has no repo access here — return complete, self-contained code.
Context: iOS 17+, SwiftUI with the #Preview macro. No external dependencies.
Task: Show a single SwiftUI file with a HydrationRing view that animates filling toward a goal.
Requirements:
- A @State progress: Double (0...1); a button raises it by 0.2, clamped at 1.0.
- The ring fill animates via withAnimation(.spring) on each change.
- Use Circle().trim(from:to:) for the ring; show the percentage as text in the centre.
- Provide three #Previews: empty (0.0), half (0.5), and complete (1.0) — pass the start value in.
Tests / acceptance (describe, since no repo):
- Tapping the button five times from 0.0 reaches a full ring (progress == 1.0) and stops there.
- Each #Preview renders its stated start state.
Output: the complete Swift file, no commentary.

Where to take it next

  • Build the full app in Vitals, where SwiftUI fronts HealthKit to turn real step and heart-rate data into a native, glanceable health dashboard with a Home Screen widget.
  • Building the same idea for Android? Compare the declarative model in Jetpack Compose.
  • Want to share business logic across iOS and Android while keeping SwiftUI for the UI? See Kotlin and its Multiplatform story.