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
BeginnerCreate 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
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, Vitals")
.font(.title)
.padding()
}
}
#Preview {
ContentView()
}Lay out content with stacks
BeginnerArrange 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
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
BeginnerAdd 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
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
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
BeginnerModel 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
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)
}
}
}
}Pass dependencies through @Environment
IntermediatePut 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
@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
AdvancedUse 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
@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
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
AdvancedRequest 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
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
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
AdvancedCreate 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
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
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
AdvancedWrap 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
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
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.