7.10 — watchOS & WatchKit

Opening scenario

The PM says: “Let’s add a Watch app. Show today’s habits, let the user complete one with a tap, and update the iPhone immediately.” You open Xcode → File → New → Target → Watch App. The template appears. Then come the questions: SwiftUI or WatchKit Storyboard? Independent or paired? Companion data flow — WatchConnectivity, App Groups, CloudKit, or a server? Complications, Smart Stack, double-tap, Always-On display? Welcome to watchOS, where the constraints are real (battery, screen, CPU, RAM) but the integration points are deep (HealthKit, ActivityKit, complications, Siri).

ContextWhat it usually means
Reads “SwiftUI on watchOS”Has built basic watch UIs
Reads “WatchConnectivity”Has done iPhone ↔ Watch data sync
Reads “complication / Smart Stack”Has shipped a watch face complication
Reads “independent watchOS app”Has built standalone apps without an iPhone counterpart
Reads “HKWorkoutSession”Has built a real-time workout app

Concept → Why → How → Code

Concept

watchOS in 2026 is SwiftUI-first, independent-app-first. The old WatchKit Storyboard approach still exists but is legacy. Modern watchOS apps are:

  • Independent — install/run without requiring an iPhone (still pair via Apple ID for personalization).
  • SwiftUI-based — same View, @State, @Observable model as iOS.
  • Complication-eligible — surface as WidgetKit widgets (yes, WidgetKit unified across iOS, macOS, watchOS).
  • HealthKit + ActivityKit native — most premium watch apps are workout or activity apps.

Why

  • At-a-glance feature — features on the watch get checked 10x more than the same feature on the phone.
  • Workout / health credibility — Apple Watch is the dominant health wearable; HealthKit + watch is the gold-standard pairing.
  • Notification first-class display — Watch notifications can have custom interactive UI via WidgetKit + AppIntents.

How — project layout

Xcode → File → New → Target → Watch App (or Watch App for iOS App if you want a paired pair).

Modern watchOS apps no longer have a separate WatchKit Extension target; the Watch App target contains both the UI and the runtime code in one bundle.

Hello, watch

import SwiftUI

@main
struct MyWatchApp: App {
    var body: some Scene {
        WindowGroup {
            TodayView()
        }
    }
}

struct TodayView: View {
    @State private var habits: [Habit] = []

    var body: some View {
        NavigationStack {
            List(habits) { habit in
                Button {
                    Task { await complete(habit) }
                } label: {
                    Label(habit.name, systemImage: habit.completed ? "checkmark.circle.fill" : "circle")
                }
            }
            .navigationTitle("Today")
            .task { habits = await HabitStore.shared.todayHabits }
        }
    }

    func complete(_ habit: Habit) async {
        await HabitStore.shared.complete(id: habit.id)
        habits = await HabitStore.shared.todayHabits
    }
}

WatchConnectivity — iPhone ↔ Watch sync

For paired apps, WatchConnectivity (WCSession) is the legacy direct-pipe. Still works.

import WatchConnectivity

@MainActor
final class ConnectivityService: NSObject, WCSessionDelegate, ObservableObject {
    static let shared = ConnectivityService()
    private let session = WCSession.default

    func activate() {
        guard WCSession.isSupported() else { return }
        session.delegate = self
        session.activate()
    }

    func sendHabits(_ habits: [Habit]) {
        guard session.activationState == .activated else { return }
        let data = (try? JSONEncoder().encode(habits)) ?? Data()
        // applicationContext: dictionary, latest-wins, persisted across launches
        try? session.updateApplicationContext(["habits": data])
    }

    // Receive
    nonisolated func session(_ session: WCSession,
                              didReceiveApplicationContext applicationContext: [String : Any]) {
        if let data = applicationContext["habits"] as? Data,
           let habits = try? JSONDecoder().decode([Habit].self, from: data) {
            Task { @MainActor in HabitStore.shared.set(habits) }
        }
    }

    nonisolated func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {}
    #if os(iOS)
    nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
    nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() }
    #endif
}

WCSession provides several APIs with different semantics:

  • updateApplicationContext — single dictionary, latest-wins, persists. Use for “current state.”
  • sendMessage(replyHandler:) — fire-and-await reply (counterpart must be reachable). Use for live request/response.
  • transferUserInfo — queued delivery, guaranteed even if counterpart asleep. Use for events.
  • transferFile — for binary blobs.

In 2026, CloudKit + SwiftData is preferred over WatchConnectivity for new apps. Both sides read/write the same iCloud container; no special connectivity dance. WatchConnectivity remains useful for low-latency interaction (e.g., the watch needs to tell the phone “user tapped this now”).

Complications & Smart Stack — via WidgetKit

watchOS 9+ unified complications with WidgetKit. A single Widget declared with .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) powers watch face complications, the Smart Stack, and the iOS Lock Screen.

struct HabitWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "Habit", provider: HabitProvider()) { entry in
            HabitWidgetView(entry: entry)
        }
        .configurationDisplayName("Habit Tracker")
        .description("Today's progress at a glance.")
        .supportedFamilies([
            .accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner,
            .systemSmall, .systemMedium // also works on iOS Lock Screen + Home Screen
        ])
    }
}

struct HabitWidgetView: View {
    @Environment(\.widgetFamily) var family
    let entry: HabitEntry

    var body: some View {
        switch family {
        case .accessoryCircular:
            Gauge(value: entry.progress) { Image(systemName: "checkmark.circle") }
                .gaugeStyle(.accessoryCircularCapacity)
        case .accessoryRectangular:
            VStack(alignment: .leading) {
                Text("Today: \(entry.completed)/\(entry.total)")
                ProgressView(value: entry.progress).tint(.accentColor)
            }
        case .accessoryInline:
            Text("\(entry.completed)/\(entry.total) habits")
        default:
            HabitProgressCard(entry: entry)
        }
    }
}

Always-On display

The watch screen stays dimly on between active interactions. SwiftUI handles most of it; for fine control:

@Environment(\.isLuminanceReduced) var isLuminanceReduced

var body: some View {
    Text("Steps: \(steps)")
        .foregroundStyle(isLuminanceReduced ? .secondary : .primary)
}

Workout sessions (real-time HealthKit on Watch)

The Apple Watch is the only place an app can run continuously with the screen-off, high-frequency sensor access. For workout-style apps:

import HealthKit

final class WorkoutSessionController: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
    let store = HKHealthStore()
    var session: HKWorkoutSession?
    var builder: HKLiveWorkoutBuilder?

    func start() throws {
        let config = HKWorkoutConfiguration()
        config.activityType = .running
        config.locationType = .outdoor
        let session = try HKWorkoutSession(healthStore: store, configuration: config)
        let builder = session.associatedWorkoutBuilder()
        builder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)
        session.delegate = self
        builder.delegate = self
        self.session = session
        self.builder = builder

        session.startActivity(with: .now)
        builder.beginCollection(withStart: .now) { _, _ in }
    }

    func end() async throws {
        session?.end()
        try await builder?.endCollection(at: .now)
        try await builder?.finishWorkout()
    }

    // Delegate stubs ...
    func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo: HKWorkoutSessionState, from: HKWorkoutSessionState, date: Date) {}
    func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {}
    func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf types: Set<HKSampleType>) {}
    func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
}

While an HKWorkoutSession is active, your app keeps running in the background with sensor access (heart rate, GPS), even with the screen off. This is the magic that powers Strava, Nike Run Club, AutoSleep, etc.

Double-tap (Apple Watch Series 9+)

Set a primary widget action. The system invokes the primary Button of your currently-foreground widget when the user double-taps. No custom API — just design widgets where the primary button is the right default action.

Siri & AppIntents on Watch

Same AppIntent you defined for iOS works on watchOS. Add the watch target to the intent’s deployment scope and it appears in Watch’s Siri + Smart Stack automatically.

In the wild

  • Strava, Nike Run Club, WHOOPHKWorkoutSession with real-time heart rate / GPS / power output.
  • Streaks, Habitify, Things 3 — independent watch apps with rich complications.
  • Carrot Weather — multi-family widget supporting every complication slot.
  • Just Press Record — independent watch app recording audio with watch-side editing.
  • Spotify, Apple Music — watch playback with offline sync.

Common misconceptions

  1. “watchOS uses UIKit.” Modern watchOS uses SwiftUI exclusively. UIKit is unavailable. The legacy WatchKit (Storyboard-based) is deprecated.
  2. “My iOS app’s data is automatically available on the watch.” No. You must either share via WatchConnectivity, App Group + SwiftData (if both targets are in the same App Group), or CloudKit.
  3. “Complications use a special framework.” Since watchOS 9, complications are WidgetKit widgets. Same Widget, different supportedFamilies.
  4. “I can run a long background task on the watch.” Only during an active HKWorkoutSession or WKExtendedRuntimeSession (limited budget, specific activity types). Otherwise watchOS suspends the app aggressively.
  5. “Watch apps need iPhone for installation.” Independent watch apps can be installed directly via the Watch’s App Store (paired-app-store-search). Most users still install via iPhone for convenience.

Seasoned engineer’s take

The temptation when writing a Watch app is to port the iPhone UI. Don’t. The Watch is a glance + tap device. The right architecture for almost every watch feature is:

  1. Open watch app → see today’s most important number (steps, habit progress, next event).
  2. One tap → an action (complete, start, dismiss).
  3. Hand off complexity to the iPhone (Handoff handle gets you to the right iPhone screen).

For data sync: in 2026, CloudKit + SwiftData with the same container shared by iOS and watchOS targets is the cleanest. The watch reads/writes; CloudKit fan-outs to the iPhone (and Mac, and iPad) automatically. WatchConnectivity is the live-channel for cases where CloudKit’s latency (seconds) is too slow.

For workouts: HKWorkoutSession is the single most powerful watchOS API. It’s the only way to get long-running, screen-off, sensor-active background execution. If your app benefits from continuous heart rate or GPS, this is the path.

TIP: Add a Button(intent:) to your most prominent widget — that becomes the double-tap target on Series 9 and later. Users love that “raise wrist → double-tap → it’s done” loop.

WARNING: Battery. The watch has a tiny battery. A bad NSTimer-driven UI in .frontmost state can drain 20% per hour. Profile with Instruments → Time Profiler against the watch simulator and a real device.

Interview corner

Junior: “How do you show a list and let the user tap to complete a habit on Apple Watch?”

Create a Watch App target. In SwiftUI, use a NavigationStack containing a List of habits, each rendered as a Button whose action toggles the completed state. Same SwiftUI you’d write for iOS, just laid out for the smaller canvas.

Mid: “How do you sync data between an iPhone app and its Watch app?”

In 2026, prefer CloudKit + SwiftData sharing the same container — both targets read/write, sync handled by iCloud. For low-latency live interaction (e.g., the watch needs to push a one-off command to the phone), use WCSession: sendMessage(replyHandler:) if both are reachable, transferUserInfo for queued delivery. For “what’s the current state” use updateApplicationContext.

Senior: “Design a running app that records GPS + heart rate for an hour, survives the screen turning off, and posts the summary to a server when the workout ends.”

Foundation is HKWorkoutSession with HKWorkoutConfiguration(activityType: .running, locationType: .outdoor). Pair with HKLiveWorkoutBuilder to collect heart rate samples and CLLocationManager (high-accuracy, allowsBackgroundLocationUpdates = true) for GPS. The active workout session keeps the app running in background with sensor access — screen-off, wrist-down, all sustained. Persist samples + route to disk (Core Data or SwiftData) every 30s as a crash safety net. On endWorkout, build a HKWorkout with route data, save to HealthKit, and POST the summary to server with retry. Battery hedge: drop GPS to kCLLocationAccuracyHundredMeters when user is stationary for >2min. UI: live metrics on watch (pace, HR, distance) using TimelineView(.periodic); full route + splits handoff to iPhone via NSUserActivity so the user can review on the bigger screen.

Red flag: “We use a 1-second timer on the watch to poll the phone for updates.”

Watch battery and CPU budget cannot sustain a 1-second polling loop. Use WCSession push semantics, or model the data on the watch as the source of truth and sync via CloudKit on natural state changes.

Lab preview

watchOS doesn’t have a dedicated lab in Phase 7; the Lab 7.2 — Widget extension stretch goal includes “add .accessoryCircular and .accessoryRectangular families so the widget appears on watch complications” — the cleanest way to ship a watch surface without committing to a full Watch App target.


Next: 7.11 — HomeKit & Matter