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).
| Context | What 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,@Observablemodel 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, WHOOP —
HKWorkoutSessionwith 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
- “watchOS uses UIKit.” Modern watchOS uses SwiftUI exclusively. UIKit is unavailable. The legacy WatchKit (Storyboard-based) is deprecated.
- “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.
- “Complications use a special framework.” Since watchOS 9, complications are WidgetKit widgets. Same
Widget, differentsupportedFamilies. - “I can run a long background task on the watch.” Only during an active
HKWorkoutSessionorWKExtendedRuntimeSession(limited budget, specific activity types). Otherwise watchOS suspends the app aggressively. - “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:
- Open watch app → see today’s most important number (steps, habit progress, next event).
- One tap → an action (complete, start, dismiss).
- 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
.frontmoststate 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
NavigationStackcontaining aListof habits, each rendered as aButtonwhose 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,transferUserInfofor queued delivery. For “what’s the current state” useupdateApplicationContext.
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
HKWorkoutSessionwithHKWorkoutConfiguration(activityType: .running, locationType: .outdoor). Pair withHKLiveWorkoutBuilderto collect heart rate samples andCLLocationManager(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. OnendWorkout, build aHKWorkoutwith route data, save to HealthKit, and POST the summary to server with retry. Battery hedge: drop GPS tokCLLocationAccuracyHundredMeterswhen user is stationary for >2min. UI: live metrics on watch (pace, HR, distance) usingTimelineView(.periodic); full route + splits handoff to iPhone viaNSUserActivityso 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
WCSessionpush 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