How to Build a Watch App in SwiftUI
Add a Watch App target in Xcode, then use SwiftUI's standard App / Scene protocol — WatchKit powers the runtime under the hood. On watchOS 10+, use TabView(.verticalPage) for the swipe-up navigation pattern Apple now recommends.
// WatchApp.swift (watchOS target)
import SwiftUI
@main
struct SoariasWatchApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
TabView {
DashboardView()
StatsView()
}
.tabViewStyle(.verticalPage)
}
}
#Preview {
ContentView()
}
Full implementation
The watch target uses the same SwiftUI App protocol you already know from iOS. The key differences are layout constraints (smaller canvas, no keyboard), the vertical-paging TabView pattern introduced in watchOS 10, and the Digital Crown — Apple Watch's signature physical input. Below is a complete, self-contained watch app with a dashboard, a detail drill-down, and Digital Crown–driven progress control, all shareable via @AppStorage with its paired iOS app through App Groups.
// ── WatchApp.swift ──────────────────────────────────────────
import SwiftUI
import WatchKit
@main
struct SoariasWatchApp: App {
@WKApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}
class AppDelegate: NSObject, WKApplicationDelegate {
func applicationDidFinishLaunching() {
// Perform any one-time watch-side setup here.
}
}
// ── RootView.swift ───────────────────────────────────────────
struct RootView: View {
var body: some View {
TabView {
DashboardView()
.tag(0)
GoalView()
.tag(1)
HistoryView()
.tag(2)
}
.tabViewStyle(.verticalPage) // watchOS 10 swipe-up gesture
}
}
// ── DashboardView.swift ──────────────────────────────────────
struct DashboardView: View {
@AppStorage("stepCount", store: UserDefaults(suiteName: "group.com.example.soarias"))
private var steps: Int = 0
var body: some View {
NavigationStack {
VStack(spacing: 8) {
Text("\(steps)")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(.green)
Text("steps today")
.font(.footnote)
.foregroundStyle(.secondary)
NavigationLink("See details") {
DetailView(steps: steps)
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
.navigationTitle("Soarias")
.navigationBarTitleDisplayMode(.inline)
}
}
}
// ── GoalView.swift – Digital Crown example ───────────────────
struct GoalView: View {
@State private var goal: Double = 8000
@State private var crownValue: Double = 8000
var progress: Double { min(1, crownValue / goal) }
var body: some View {
VStack(spacing: 6) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.3), lineWidth: 8)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut, value: progress)
Text("\(Int(crownValue))")
.font(.headline)
}
.frame(width: 90, height: 90)
.focusable()
.digitalCrownRotation(
$crownValue,
from: 1000,
through: 20000,
by: 500,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
Text("Adjust goal with Crown")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
// ── HistoryView.swift ────────────────────────────────────────
struct HistoryView: View {
let samples: [(String, Int)] = [
("Mon", 9_234), ("Tue", 6_120), ("Wed", 11_050),
("Thu", 7_800), ("Fri", 5_430)
]
var body: some View {
List(samples, id: \.0) { day, count in
HStack {
Text(day).font(.footnote).foregroundStyle(.secondary)
Spacer()
Text("\(count)").font(.footnote.bold())
}
}
.listStyle(.carousel)
.navigationTitle("History")
}
}
// ── DetailView.swift ─────────────────────────────────────────
struct DetailView: View {
let steps: Int
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Label("\(steps) steps", systemImage: "figure.walk")
Label("~\(steps / 1300) km", systemImage: "map")
Label("\(steps * 4) cal", systemImage: "flame")
}
.font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Detail")
}
}
// ── Previews ─────────────────────────────────────────────────
#Preview("Root") {
RootView()
}
#Preview("Goal / Crown") {
GoalView()
}
#Preview("History") {
HistoryView()
}
How it works
-
@WKApplicationDelegateAdaptor— Bridges WatchKit's delegate lifecycle into the SwiftUIAppprotocol. UseAppDelegatefor tasks like WCSession activation or background session setup that don't belong in aView. -
TabView(.verticalPage)— The watchOS 10 navigation paradigm. Users swipe up/down between tabs rather than tapping a bottom bar. Each child view becomes a full-screen page; the small dots indicator is rendered automatically. -
.digitalCrownRotation(_:from:through:by:sensitivity:)— Binds the Digital Crown to aDoublestate value. TheisHapticFeedbackEnabled: trueflag produces a tactile click every 500-step increment, matching theby:parameter. -
@AppStoragewith App Groups — Pass a sharedUserDefaultssuite (group.com.example.*) to both the watch target and its iOS companion. Any write on one side is immediately visible on the other without a WCSession message round-trip for simple values. -
.listStyle(.carousel)— watchOS-only list style that curves rows around the bezel, a native "look" that.plainand.insetGroupeddon't provide on the watch.
Variants
WidgetKit complication (watch face tile)
Since watchOS 9, complications are built with WidgetKit — the same API used for iOS 17 Lock Screen widgets. Add a Widget Extension target, then use @main with WidgetBundle:
import WidgetKit
import SwiftUI
struct StepsEntry: TimelineEntry {
let date: Date
let steps: Int
}
struct StepsProvider: TimelineProvider {
func placeholder(in context: Context) -> StepsEntry {
StepsEntry(date: .now, steps: 8_000)
}
func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {
completion(StepsEntry(date: .now, steps: 9_234))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
let entry = StepsEntry(date: .now, steps: readStepsFromAppGroup())
let next = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
private func readStepsFromAppGroup() -> Int {
UserDefaults(suiteName: "group.com.example.soarias")?.integer(forKey: "stepCount") ?? 0
}
}
struct StepsComplicationView: View {
let entry: StepsEntry
var body: some View {
VStack {
Image(systemName: "figure.walk").font(.title3)
Text("\(entry.steps / 1000)k").font(.footnote.bold())
}
.widgetAccentable()
}
}
@main
struct SoariasComplications: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "steps", provider: StepsProvider()) { entry in
StepsComplicationView(entry: entry)
}
.configurationDisplayName("Steps")
.description("Today's step count.")
.supportedFamilies([.accessoryCircular, .accessoryCorner, .accessoryRectangular])
}
}
WatchConnectivity real-time sync
For data that needs to arrive instantly (e.g. a timer started on iPhone), use WCSession from WatchConnectivity. Call WCSession.default.sendMessage(_:replyHandler:errorHandler:) from the iOS side and implement session(_:didReceiveMessage:) in your watch WCSessionDelegate. Keep the delegate as a singleton @Observable class and inject it via .environment() for clean propagation through the SwiftUI hierarchy.
Common pitfalls
-
watchOS version floor:
TabView(.verticalPage)and.accessoryCircularWidgetKit families both require watchOS 10+. If your deployment target is watchOS 9, wrap in#if os(watchOS)availability checks or accept that the App Store will gate installs by device OS. -
Forgetting
.focusable()before.digitalCrownRotation: The crown binding silently does nothing if the view isn't focusable. Always pair.focusable()immediately above.digitalCrownRotation— it's easy to miss and produces no compiler warning. -
No background refresh without entitlements: watchOS aggressively suspends apps. If you need periodic data updates (e.g. live health data), request the Background App Refresh entitlement and use
WKExtendedRuntimeSessionfor workout or mindfulness sessions — don't assume aTimerwill fire when the watch face is active. -
App Group suite must match exactly: A mismatch between the suite name in the iOS target and the watchOS target results in
nilvalues with no error. Enable the App Groups capability in both targets in Signing & Capabilities and verify the group identifier is identical, character for character.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a watch app in SwiftUI for iOS 17+ / watchOS 10+. Use WatchKit (@WKApplicationDelegateAdaptor), TabView(.verticalPage), digitalCrownRotation, and WidgetKit for a complication. Share data with the iOS companion via an App Group UserDefaults suite. Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data for each view.
In Soarias's Build phase, paste this prompt after scaffolding your iOS target — Soarias will add the watchOS target, wire up the App Group entitlement in both targets, and generate the full file tree ready for Xcode.
Related
FAQ
Does this work on watchOS 9 / paired iOS 16?
Partially. The App protocol and basic SwiftUI views work from watchOS 7+. However, TabView(.verticalPage) requires watchOS 10, and WidgetKit complications require watchOS 9. Set your watchOS deployment target in Xcode's project settings and guard any watchOS 10-exclusive APIs with @available(watchOS 10, *) to gracefully degrade on older watches.
How do I send data from iPhone to Apple Watch in real time?
Use WatchConnectivity's WCSession. For non-urgent data (settings, cumulative counts), prefer the Application Context (updateApplicationContext(_:)) — it coalesces updates and delivers when the watch wakes. For time-critical messages (a started timer, a command), use sendMessage(_:replyHandler:), which requires both devices to have an active session and the watch app to be in the foreground. App Group UserDefaults is the simplest option for values written infrequently from the iPhone.
What's the UIKit / WatchKit storyboard equivalent?
Before watchOS 7, all Apple Watch apps used WatchKit's storyboard-based interface with WKInterfaceController subclasses — the direct watchOS analog of UIKit's UIViewController. Storyboard-based watch apps still compile but are deprecated. Apple recommends migrating to the SwiftUI App protocol. If you're maintaining an older app, you can incrementally adopt SwiftUI by hosting SwiftUI views inside a WKInterfaceController via WKHostingController<Content>.
Last reviewed: 2026-05-12 by the Soarias team.