
Watchos
- 452 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
watchos is a Claude Code Apple skill that guides development of watchOS apps and complications with SwiftUI, WatchKit, health and sensor APIs, and iPhone companion sync for developers targeting Apple Watch platform const
About
watchos is a skill from rshankras/claude-code-apple-skills for building Apple Watch experiences. It covers watchOS apps and complications using SwiftUI and WatchKit, integration with health and sensor APIs, and synchronization patterns with an iPhone companion app while respecting watch platform limits on screen size, battery, and background execution. Developers reach for watchos when extending an iOS product to the wrist, adding complications to the watch face, or implementing health and sensor-driven features that must comply with Apple's watchOS guidelines.
- SwiftUI watch interface patterns
- Complications and glance layouts
- HealthKit and sensor APIs
- iPhone companion app sync
- watchOS performance constraints
Watchos by the numbers
- 452 all-time installs (skills.sh)
- +24 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #316 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill watchosAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 452 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
How do you build watchOS apps with SwiftUI?
Develop watchOS apps and complications with SwiftUI, WatchKit, health and sensor APIs, and iPhone companion sync following Apple watch platform constraints.
Who is it for?
iOS developers adding Apple Watch targets, complications, or health and sensor features with SwiftUI and WatchKit in an existing Apple ecosystem app.
Skip if: Android Wear development, cross-platform Flutter watch prototypes, or backend-only API work with no watchOS target.
When should I use this skill?
The user develops watchOS apps, complications, health or sensor features, or iPhone-watch companion sync with SwiftUI and WatchKit.
What you get
watchOS SwiftUI views, WatchKit complications, health or sensor integrations, and iPhone companion sync patterns following platform constraints.
- watchOS SwiftUI views
- complication definitions
- companion sync implementation
Files
watchOS Development
Comprehensive guidance for watchOS app development with SwiftUI, Watch Connectivity, and complications.
When This Skill Activates
Use this skill when the user:
- Is building a watchOS app or Watch extension
- Asks about Watch Connectivity (iPhone ↔ Watch sync)
- Needs help with complications or ClockKit
- Wants to implement watch-specific UI patterns
- Asks about WidgetKit complications or migrating from ClockKit to WidgetKit
- Wants to build watch face complications (accessoryCircular, accessoryRectangular, accessoryCorner, accessoryInline)
- Asks about HealthKit on watchOS, workout sessions, heart rate, or fitness tracking
- Needs Extended Runtime sessions for background workout tracking
- Wants to build watchOS widgets or Smart Stack widgets
- Asks about widget relevance, Smart Stack ordering, or widget suggestions
- Needs to share widgets cross-platform between iOS and watchOS
Key Principles
1. Watch-First Design
- Glanceable content - users look for seconds, not minutes
- Quick interactions - 2 seconds or less
- Essential information only - no scrolling walls of text
- Large touch targets - minimum 38pt height
2. Independent vs Companion
- Prefer independent Watch apps when possible
- Use Watch Connectivity for data sync, not as dependency
- Cache data locally for offline access
- Handle connectivity failures gracefully
3. Performance
- Minimize background work (battery)
- Use complication updates sparingly
- Prefer timeline-based content over live updates
- Keep views lightweight
Architecture Patterns
App Structure
@main
struct MyWatchApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}Navigation
// Use NavigationStack (watchOS 9+)
NavigationStack {
List {
NavigationLink("Item 1", value: Item.one)
NavigationLink("Item 2", value: Item.two)
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
// TabView for main sections
TabView {
HomeView()
ActivityView()
SettingsView()
}
.tabViewStyle(.verticalPage)List Design
List {
ForEach(items) { item in
ItemRow(item: item)
}
.onDelete(perform: delete)
}
.listStyle(.carousel) // For focused content
.listStyle(.elliptical) // For browsingWatch Connectivity
Session Setup
import WatchConnectivity
@Observable
final class WatchConnectivityManager: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityManager()
private(set) var isReachable = false
override init() {
super.init()
if WCSession.isSupported() {
WCSession.default.delegate = self
WCSession.default.activate()
}
}
// Required delegate methods
func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {
isReachable = session.isReachable
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
#endif
}Data Transfer Methods
| Method | Use Case | Delivery |
|---|---|---|
updateApplicationContext | Latest state (settings) | Overwrites previous |
sendMessage | Real-time, both apps active | Immediate |
transferUserInfo | Queued data | Guaranteed, in order |
transferFile | Large data | Background transfer |
// Application Context (most common)
func updateContext(_ data: [String: Any]) throws {
try WCSession.default.updateApplicationContext(data)
}
// Real-time messaging
func sendMessage(_ message: [String: Any]) {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage(message, replyHandler: nil)
}
// Receiving data
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
Task { @MainActor in
// Update UI with received data
}
}Complications
Timeline Provider
import ClockKit
struct ComplicationController: CLKComplicationDataSource {
func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) {
let descriptor = CLKComplicationDescriptor(
identifier: "myComplication",
displayName: "My App",
supportedFamilies: [.circularSmall, .modularSmall, .graphicCircular]
)
handler([descriptor])
}
func getCurrentTimelineEntry(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void
) {
let template = makeTemplate(for: complication.family)
let entry = CLKComplicationTimelineEntry(date: .now, complicationTemplate: template)
handler(entry)
}
}WidgetKit Complications (watchOS 9+)
import WidgetKit
import SwiftUI
struct MyComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "MyComplication",
provider: ComplicationProvider()
) { entry in
ComplicationView(entry: entry)
}
.configurationDisplayName("My Complication")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryCorner,
.accessoryInline
])
}
}UI Components
Digital Crown
@State private var crownValue = 0.0
ScrollView {
// Content
}
.focusable()
.digitalCrownRotation($crownValue)Haptic Feedback
WKInterfaceDevice.current().play(.click)
WKInterfaceDevice.current().play(.success)
WKInterfaceDevice.current().play(.failure)Now Playing
import WatchKit
NowPlayingView() // Built-in now playing controlsWorkout Apps
import HealthKit
@Observable
class WorkoutManager {
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
func startWorkout(type: HKWorkoutActivityType) async throws {
let config = HKWorkoutConfiguration()
config.activityType = type
config.locationType = .outdoor
session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
builder = session?.associatedWorkoutBuilder()
session?.startActivity(with: .now)
try await builder?.beginCollection(at: .now)
}
}Best Practices
Performance
- Use
@ObservableoverObservableObject(watchOS 10+) - Limit background refreshes
- Cache images locally
- Use lazy loading for lists
Battery
- Minimize location updates
- Use scheduled background tasks
- Prefer complications over frequent refreshes
- Batch network requests
User Experience
- Always show loading states
- Provide haptic feedback
- Support keyboard input
- Use clear iconography
Testing
Simulator
- Test with different watch sizes
- Verify complications in all families
- Test Watch Connectivity with paired iPhone simulator
On Device
- Test battery impact
- Verify haptics feel appropriate
- Test in different lighting conditions
Decision Tree
Choose the right reference file based on what the user needs:
What are you building?
|
+- iPhone <-> Watch data sync
| -> watch-connectivity.md
| +- Session management, application context, real-time messaging
| +- File transfers, offline caching, complication push updates
|
+- Watch face complications
| -> complications.md
| +- ClockKit (legacy) vs WidgetKit (modern) complications
| +- Migration from ClockKit to WidgetKit
| +- Complication families (circular, rectangular, corner, inline)
| +- Timeline providers, reload strategies, gauges
|
+- Health / fitness / workout tracking
| -> health-fitness.md
| +- HealthKit authorization and data types
| +- HKWorkoutSession and HKLiveWorkoutBuilder
| +- Real-time heart rate, calories, distance
| +- Extended Runtime sessions, route tracking
|
+- watchOS widgets / Smart Stack
| -> widgets-for-watch.md
| +- Smart Stack configuration and relevance
| +- Cross-platform widget sharing (iOS + watchOS)
| +- watchOS-specific design (dark background, small screen)
|
+- General watchOS app development
-> This file (SKILL.md)
+- App structure, navigation, lists
+- Digital Crown, haptics, Now PlayingReference Files
| File | Content |
|---|---|
| watch-connectivity.md | iPhone <-> Watch sync, session management, data transfer, offline caching |
| complications.md | ClockKit to WidgetKit migration, complication families, timeline providers, gauges |
| health-fitness.md | HealthKit, workout sessions, heart rate, Extended Runtime, route tracking, privacy |
| widgets-for-watch.md | Smart Stack widgets, relevance, cross-platform sharing, watchOS design |
External References
Watch Complications
Detailed guide for building watch face complications, covering the ClockKit to WidgetKit migration and modern complication patterns.
ClockKit vs WidgetKit Complications
| Feature | ClockKit (Legacy) | WidgetKit (Modern) |
|---|---|---|
| Minimum target | watchOS 2 | watchOS 9 |
| Language | Swift/ObjC templates | SwiftUI views |
| Families | CLKComplicationFamily | WidgetFamily (accessory*) |
| Data source | CLKComplicationDataSource | TimelineProvider |
| Deprecated | watchOS 9 | Current |
| Removal | watchOS 11 | N/A |
Rule: All new complications must use WidgetKit. ClockKit is deprecated and removed in watchOS 11.
Migration: ClockKit to WidgetKit
Before (ClockKit)
import ClockKit
class ComplicationController: NSObject, CLKComplicationDataSource {
func getComplicationDescriptors(
handler: @escaping ([CLKComplicationDescriptor]) -> Void
) {
let descriptor = CLKComplicationDescriptor(
identifier: "steps",
displayName: "Steps",
supportedFamilies: [.graphicCircular, .graphicRectangular]
)
handler([descriptor])
}
func getCurrentTimelineEntry(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void
) {
let template: CLKComplicationTemplate
switch complication.family {
case .graphicCircular:
let t = CLKComplicationTemplateGraphicCircularStackText()
t.line1TextProvider = CLKSimpleTextProvider(text: "Steps")
t.line2TextProvider = CLKSimpleTextProvider(text: "8,432")
template = t
default:
handler(nil)
return
}
handler(CLKComplicationTimelineEntry(date: .now, complicationTemplate: template))
}
}After (WidgetKit)
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: 0)
}
func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {
completion(StepsEntry(date: .now, steps: 8432))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
let entry = StepsEntry(date: .now, steps: DataStore.shared.todaySteps)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct StepsComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "Steps", provider: StepsProvider()) { entry in
StepsComplicationView(entry: entry)
}
.configurationDisplayName("Steps")
.description("Today's step count.")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}Migration Checklist
1. Replace CLKComplicationDataSource with TimelineProvider 2. Replace CLKComplicationTemplate with SwiftUI views 3. Replace CLKTextProvider with SwiftUI Text 4. Replace CLKComplicationDescriptor with Widget struct 5. Replace CLKComplicationServer.reloadTimeline(for:) with WidgetCenter.shared.reloadTimelines(ofKind:) 6. Remove CLKComplicationDataSource from Info.plist 7. Add widget extension target if not present
Complication Families
.accessoryCircular
Small circular complication. Use for single metrics with gauges or icons.
struct CircularView: View {
let entry: StepsEntry
var body: some View {
Gauge(value: Double(entry.steps), in: 0...10000) {
Text("Steps")
} currentValueLabel: {
Text("\(entry.steps)")
}
.gaugeStyle(.accessoryCircularCapacity)
}
}.accessoryRectangular
Multi-line rectangular area. Best for text-heavy content or small charts.
struct RectangularView: View {
let entry: StepsEntry
var body: some View {
VStack(alignment: .leading) {
Text("Steps")
.font(.headline)
.widgetAccentable()
Text("\(entry.steps)")
.font(.title2)
ProgressView(value: Double(entry.steps), total: 10000)
}
}
}.accessoryCorner
Corner position with curved text or gauge. Available on specific watch faces.
struct CornerView: View {
let entry: StepsEntry
var body: some View {
Text("\(entry.steps)")
.font(.title3)
.widgetCurvesContent()
.widgetLabel {
Gauge(value: Double(entry.steps), in: 0...10000) {
Text("Steps")
}
.gaugeStyle(.accessoryLinearCapacity)
}
}
}.accessoryInline
Single line of text, appears on the watch face. Text only, no images.
struct InlineView: View {
let entry: StepsEntry
var body: some View {
Text("\(entry.steps) steps today")
}
}Switching by Family
struct StepsComplicationView: View {
@Environment(\.widgetFamily) var family
let entry: StepsEntry
var body: some View {
switch family {
case .accessoryCircular:
CircularView(entry: entry)
case .accessoryRectangular:
RectangularView(entry: entry)
case .accessoryCorner:
CornerView(entry: entry)
case .accessoryInline:
InlineView(entry: entry)
default:
Text("\(entry.steps)")
}
}
}Timeline Providers
StaticConfiguration (No User Configuration)
struct SimpleProvider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, value: 0)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
completion(SimpleEntry(date: .now, value: 42))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
var entries: [SimpleEntry] = []
let now = Date.now
// Create entries for the next 4 hours
for hourOffset in 0..<4 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: now)!
let value = fetchValue(for: entryDate)
entries.append(SimpleEntry(date: entryDate, value: value))
}
completion(Timeline(entries: entries, policy: .after(entries.last!.date)))
}
}AppIntentConfiguration (User-Configurable, watchOS 10+)
import AppIntents
struct MetricIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Metric"
@Parameter(title: "Metric")
var metric: MetricType
enum MetricType: String, AppEnum {
case steps, calories, distance
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric")
static var caseDisplayRepresentations: [MetricType: DisplayRepresentation] = [
.steps: "Steps",
.calories: "Calories",
.distance: "Distance"
]
}
}
struct ConfigurableProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> MetricEntry {
MetricEntry(date: .now, value: 0, metric: .steps)
}
func snapshot(for configuration: MetricIntent, in context: Context) async -> MetricEntry {
MetricEntry(date: .now, value: 42, metric: configuration.metric)
}
func timeline(for configuration: MetricIntent, in context: Context) async -> Timeline<MetricEntry> {
let value = await fetchMetric(configuration.metric)
let entry = MetricEntry(date: .now, value: value, metric: configuration.metric)
return Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(900)))
}
}Timeline Reload Strategies
Reload Policies
| Policy | When to Use |
|---|---|
.atEnd | Reload when the last entry's date passes |
.after(Date) | Reload at a specific future date |
.never | Only reload on explicit request |
Triggering Reloads
import WidgetKit
// Reload a specific complication
WidgetCenter.shared.reloadTimelines(ofKind: "Steps")
// Reload all complications
WidgetCenter.shared.reloadAllTimelines()
// From iPhone via Watch Connectivity
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
if userInfo["complicationUpdate"] != nil {
WidgetCenter.shared.reloadTimelines(ofKind: "Steps")
}
}Complication Data Patterns
Gauge Styles
// Circular capacity gauge
Gauge(value: 0.7) { Text("HR") }
.gaugeStyle(.accessoryCircularCapacity)
// Linear capacity gauge (for corner/rectangular)
Gauge(value: 0.7) { Text("HR") }
.gaugeStyle(.accessoryLinearCapacity)
// Circular open gauge with tint
Gauge(value: heartRate, in: 60...200) {
Image(systemName: "heart.fill")
} currentValueLabel: {
Text("\(Int(heartRate))")
}
.gaugeStyle(.accessoryCircular)
.tint(.red)Date and Timer Display
// Relative date (e.g., "2 hours ago")
Text(entry.lastUpdated, style: .relative)
// Timer countdown
Text(entry.targetDate, style: .timer)
// Specific date format
Text(entry.date, format: .dateTime.hour().minute())Accent and Tint
VStack {
Image(systemName: "flame.fill")
.widgetAccentable() // Tinted by watch face color
Text("\(calories)")
}
// Full-color rendering for Modular Duo, X-Large
.widgetRenderingMode(.fullColor)Best Practices
1. Budget-aware updates -- Complications get limited background execution time. Batch data fetches and minimize network calls in the timeline provider.
2. Lightweight timelines -- Provide 4-8 timeline entries maximum. Avoid creating entries for every minute.
3. Meaningful placeholders -- The placeholder is shown while the complication loads. Use realistic but static data, never zeros or empty strings.
4. Handle stale data -- Show the last-known value with a timestamp rather than showing nothing when data is unavailable.
5. Respect rendering mode -- Use @Environment(\.widgetRenderingMode) to adapt between .fullColor, .accented, and .vibrant.
6. Test all families -- Each family has different space constraints. Preview every supported family in Xcode.
7. Minimal text in circular -- Circular complications have very limited space. Use gauges, icons, or single numbers.
8. Use `.widgetAccentable()` -- Mark elements that should adopt the watch face accent color.
9. Shared data via App Groups -- Use a shared App Group container to pass data between the main app and the widget extension.
// In both main app and widget extension
let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")
sharedDefaults?.set(stepCount, forKey: "todaySteps")10. Complication reload limits -- reloadTimelines is budget-limited. Don't call it more than 4 times per hour. Use timeline entries with future dates instead.
Health & Fitness on watchOS
Detailed guide for HealthKit, workout sessions, sensor data, and extended runtime on Apple Watch.
HealthKit Authorization
Requesting Permissions
import HealthKit
@Observable
final class HealthManager {
let healthStore = HKHealthStore()
var isAuthorized = false
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthError.notAvailable
}
let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.stepCount),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.vo2Max),
HKObjectType.workoutType()
]
let typesToWrite: Set<HKSampleType> = [
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.distanceWalkingRunning),
HKObjectType.workoutType()
]
try await healthStore.requestAuthorization(
toShare: typesToWrite,
read: typesToRead
)
isAuthorized = true
}
}Info.plist Keys
Add these keys to the watchOS app's Info.plist:
| Key | Purpose |
|---|---|
NSHealthShareUsageDescription | Why you read health data |
NSHealthUpdateUsageDescription | Why you write health data |
Authorization Status
func checkAuthorizationStatus(for type: HKQuantityType) -> HKAuthorizationStatus {
healthStore.authorizationStatus(for: type)
}
// Note: HealthKit only tells you if the user has responded,
// not whether they granted or denied access (for privacy).
// .notDetermined = user hasn't been asked
// .sharingAuthorized = user allowed writing
// .sharingDenied = user denied writing
// Reading status is always .notDetermined for privacy reasons.Workout Sessions
Complete Workout Manager
import HealthKit
@Observable
final class WorkoutManager: NSObject {
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
// Real-time metrics
var heartRate: Double = 0
var activeCalories: Double = 0
var distance: Double = 0
var elapsedTime: TimeInterval = 0
var isActive: Bool { session?.state == .running }
// MARK: - Start Workout
func startWorkout(type: HKWorkoutActivityType, location: HKWorkoutSessionLocationType = .outdoor) async throws {
let config = HKWorkoutConfiguration()
config.activityType = type
config.locationType = location
session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
builder = session?.associatedWorkoutBuilder()
session?.delegate = self
builder?.delegate = self
builder?.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore,
workoutConfiguration: config
)
let startDate = Date.now
session?.startActivity(with: startDate)
try await builder?.beginCollection(at: startDate)
}
// MARK: - Pause / Resume / End
func pause() {
session?.pause()
}
func resume() {
session?.resume()
}
func endWorkout() async throws {
session?.end()
try await builder?.endCollection(at: .now)
try await builder?.finishWorkout()
// Reset state
session = nil
builder = nil
}
}
// MARK: - HKWorkoutSessionDelegate
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
// Handle state changes (running, paused, ended)
}
func workoutSession(
_ workoutSession: HKWorkoutSession,
didFailWithError error: Error
) {
print("Workout session error: \(error)")
}
}
// MARK: - HKLiveWorkoutBuilderDelegate
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
// Handle workout events (pause, resume markers)
}
func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
Task { @MainActor in
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
updateMetric(for: quantityType, from: workoutBuilder)
}
}
}
@MainActor
private func updateMetric(for type: HKQuantityType, from builder: HKLiveWorkoutBuilder) {
guard let statistics = builder.statistics(for: type) else { return }
switch type {
case HKQuantityType(.heartRate):
let unit = HKUnit.count().unitDivided(by: .minute())
heartRate = statistics.mostRecentQuantity()?.doubleValue(for: unit) ?? 0
case HKQuantityType(.activeEnergyBurned):
activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
case HKQuantityType(.distanceWalkingRunning):
distance = statistics.sumQuantity()?.doubleValue(for: .meter()) ?? 0
default:
break
}
elapsedTime = builder.elapsedTime
}
}Workout SwiftUI View
struct WorkoutView: View {
let manager: WorkoutManager
var body: some View {
VStack(spacing: 8) {
// Heart rate
HStack {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
Text("\(Int(manager.heartRate))")
.font(.system(.title, design: .rounded).monospacedDigit())
Text("BPM")
.font(.caption)
.foregroundStyle(.secondary)
}
// Calories
HStack {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
Text("\(Int(manager.activeCalories))")
.font(.system(.title2, design: .rounded).monospacedDigit())
Text("CAL")
.font(.caption)
.foregroundStyle(.secondary)
}
// Distance
HStack {
Image(systemName: "figure.run")
.foregroundStyle(.green)
Text(Measurement(value: manager.distance, unit: UnitLength.meters),
format: .measurement(width: .abbreviated, usage: .road))
.font(.system(.title2, design: .rounded).monospacedDigit())
}
// Elapsed time
Text(Duration.seconds(manager.elapsedTime),
format: .time(pattern: .hourMinuteSecond))
.font(.system(.title3, design: .rounded).monospacedDigit())
.foregroundStyle(.yellow)
}
}
}Extended Runtime Sessions
Use WKExtendedRuntimeSession to keep your app running in the background for workouts, health monitoring, or self-care.
Session Types
| Type | Duration | Use Case |
|---|---|---|
.workout | Unlimited (while workout active) | Workout tracking via HKWorkoutSession |
.selfCare | Up to 10 minutes | Guided breathing, stretching |
.mindfulness | Up to 10 minutes | Meditation sessions |
.smartAlarm | Up to 30 minutes before alarm | Smart wake-up |
.physicalTherapy | Up to 1 hour | Guided exercises |
Background Workout Session
@Observable
final class ExtendedWorkoutManager: NSObject {
private var extendedSession: WKExtendedRuntimeSession?
func startExtendedSession() {
let session = WKExtendedRuntimeSession()
session.delegate = self
session.start()
self.extendedSession = session
}
func stopExtendedSession() {
extendedSession?.invalidate()
extendedSession = nil
}
}
extension ExtendedWorkoutManager: WKExtendedRuntimeSessionDelegate {
func extendedRuntimeSessionDidStart(_ session: WKExtendedRuntimeSession) {
// Session started, app continues running in background
}
func extendedRuntimeSessionWillExpire(_ session: WKExtendedRuntimeSession) {
// Save state, session is about to end
}
func extendedRuntimeSession(
_ session: WKExtendedRuntimeSession,
didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
error: Error?
) {
// Session ended
extendedSession = nil
}
}Note: HKWorkoutSession automatically provides background execution. Use WKExtendedRuntimeSession only when you need background time outside of a workout (e.g., a meditation timer).
HealthKit Queries
Statistics Query (Aggregated Data)
func fetchTodaySteps() async throws -> Double {
let stepsType = HKQuantityType(.stepCount)
let startOfDay = Calendar.current.startOfDay(for: .now)
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: .now,
options: .strictStartDate
)
let descriptor = HKStatisticsQueryDescriptor(
predicate: HKSamplePredicate<HKQuantitySample>.quantitySample(
type: stepsType,
predicate: predicate
),
options: .cumulativeSum
)
let result = try await descriptor.result(for: healthStore)
return result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
}Anchored Object Query (Incremental Updates)
func observeHeartRate() -> AsyncStream<Double> {
AsyncStream { continuation in
let heartRateType = HKQuantityType(.heartRate)
let predicate = HKQuery.predicateForSamples(
withStart: .now,
end: nil,
options: .strictStartDate
)
let query = HKAnchoredObjectQuery(
type: heartRateType,
predicate: predicate,
anchor: nil,
limit: HKObjectQueryNoLimit
) { _, samples, _, _, _ in
guard let sample = samples?.last as? HKQuantitySample else { return }
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))
continuation.yield(bpm)
}
query.updateHandler = { _, samples, _, _, _ in
guard let sample = samples?.last as? HKQuantitySample else { return }
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))
continuation.yield(bpm)
}
healthStore.execute(query)
continuation.onTermination = { @Sendable _ in
self.healthStore.stop(query)
}
}
}Statistics Collection Query (Time Series)
func fetchHourlyHeartRate() async throws -> [(date: Date, bpm: Double)] {
let heartRateType = HKQuantityType(.heartRate)
let startOfDay = Calendar.current.startOfDay(for: .now)
let interval = DateComponents(hour: 1)
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: .now,
options: .strictStartDate
)
let descriptor = HKStatisticsCollectionQueryDescriptor(
predicate: HKSamplePredicate<HKQuantitySample>.quantitySample(
type: heartRateType,
predicate: predicate
),
options: .discreteAverage,
anchorDate: startOfDay,
intervalComponents: interval
)
let results = try await descriptor.result(for: healthStore)
let unit = HKUnit.count().unitDivided(by: .minute())
var hourlyData: [(date: Date, bpm: Double)] = []
results.enumerateStatistics(from: startOfDay, to: .now) { stats, _ in
if let avg = stats.averageQuantity()?.doubleValue(for: unit) {
hourlyData.append((date: stats.startDate, bpm: avg))
}
}
return hourlyData
}Workout Route Tracking
import CoreLocation
@Observable
final class RouteTracker: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private var routeBuilder: HKWorkoutRouteBuilder?
private let healthStore = HKHealthStore()
var locations: [CLLocation] = []
func startTracking() {
routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: nil)
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.allowsBackgroundLocationUpdates = true
locationManager.startUpdatingLocation()
}
func stopTracking(workout: HKWorkout) async throws {
locationManager.stopUpdatingLocation()
guard let routeBuilder else { return }
try await routeBuilder.finishRoute(with: workout, metadata: nil)
}
// CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations newLocations: [CLLocation]) {
let filtered = newLocations.filter { $0.horizontalAccuracy < 20 }
guard !filtered.isEmpty else { return }
locations.append(contentsOf: filtered)
routeBuilder?.insertRouteData(filtered) { success, error in
if let error {
print("Route insert error: \(error)")
}
}
}
}Required entitlements and Info.plist keys:
| Key | Value |
|---|---|
NSLocationWhenInUseUsageDescription | Why you track location |
NSLocationAlwaysAndWhenInUseUsageDescription | For background tracking |
| Background Modes | location enabled |
Haptics During Workouts
import WatchKit
enum WorkoutHaptic {
/// Notify user of a milestone (e.g., 1 km reached)
static func milestone() {
WKInterfaceDevice.current().play(.success)
}
/// Heart rate zone change
static func zoneChange() {
WKInterfaceDevice.current().play(.directionUp)
}
/// Workout paused or resumed
static func stateChange() {
WKInterfaceDevice.current().play(.click)
}
/// Countdown beep (3, 2, 1, Go)
static func countdownTick() {
WKInterfaceDevice.current().play(.start)
}
/// Workout complete
static func workoutComplete() {
WKInterfaceDevice.current().play(.success)
}
}Water Lock
// Enable water lock during swimming workouts
WKInterfaceDevice.current().enableWaterLock()Saving Workout Data
Adding Samples to a Workout
func saveWorkoutWithSamples(
builder: HKLiveWorkoutBuilder,
type: HKWorkoutActivityType
) async throws {
// End collection
let endDate = Date.now
try await builder.endCollection(at: endDate)
// The builder automatically collects samples from its data source.
// To add manual samples:
let caloriesSample = HKQuantitySample(
type: HKQuantityType(.activeEnergyBurned),
quantity: HKQuantity(unit: .kilocalorie(), doubleValue: 320),
start: builder.startDate ?? endDate,
end: endDate
)
try await builder.addSamples([caloriesSample])
// Finish and save the workout
let workout = try await builder.finishWorkout()
print("Workout saved: \(workout)")
}Adding Workout Events
// Mark intervals, laps, or segments
let lapEvent = HKWorkoutEvent(
type: .lap,
dateInterval: DateInterval(start: lapStart, end: .now),
metadata: ["lapNumber": lapCount]
)
builder?.addWorkoutEvents([lapEvent]) { success, error in
if let error { print("Event error: \(error)") }
}Privacy Considerations
1. Request only needed types -- Never request more HealthKit data types than your app requires. Apple reviews this during App Review.
2. Explain clearly -- The usage description strings must clearly explain why the data is needed in the user's language.
3. Graceful denial -- The app must function (with reduced features) if the user denies health data access.
4. No external sharing -- HealthKit data must not be sold, shared with advertising, or transmitted to third parties without explicit consent.
5. Encrypted storage -- Any health data cached locally must use Data Protection (FileProtectionType.complete).
6. No iCloud backup -- Do not store HealthKit data in iCloud or unprotected containers. Use HealthKit itself as the source of truth.
Best Practices
1. Always check availability -- HKHealthStore.isHealthDataAvailable() returns false on iPad and other unsupported devices.
2. Use async/await APIs -- Prefer the HKStatisticsQueryDescriptor and HKStatisticsCollectionQueryDescriptor async APIs over completion-handler queries when targeting watchOS 10+.
3. Batch saves -- Collect samples during the workout and let HKLiveWorkoutBuilder handle saving. Don't save individual samples mid-workout.
4. Handle background delivery -- Use healthStore.enableBackgroundDelivery(for:frequency:) to receive updates when your app is in the background.
5. Test on device -- HealthKit simulation in Xcode is limited. Always test sensor data, workout sessions, and background behavior on a real Apple Watch.
Watch Connectivity Patterns
Best practices for iPhone ↔ Apple Watch communication.
Session Management
Complete Session Manager
import WatchConnectivity
import Foundation
/// Manages Watch Connectivity between iPhone and Apple Watch.
@Observable
final class WatchConnectivityManager: NSObject, @unchecked Sendable {
// MARK: - Singleton
static let shared = WatchConnectivityManager()
// MARK: - State
private(set) var activationState: WCSessionActivationState = .notActivated
private(set) var isReachable = false
private(set) var isCompanionAppInstalled = false
#if os(iOS)
private(set) var isPaired = false
private(set) var isWatchAppInstalled = false
#endif
// MARK: - Callbacks
var onContextReceived: (([String: Any]) -> Void)?
var onMessageReceived: (([String: Any]) -> Void)?
var onUserInfoReceived: (([String: Any]) -> Void)?
// MARK: - Initialization
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else {
print("⌚ WCSession not supported")
return
}
WCSession.default.delegate = self
WCSession.default.activate()
}
// MARK: - Sending Data
/// Update application context (latest state, overwrites previous).
func updateContext(_ context: [String: Any]) throws {
guard activationState == .activated else {
throw WatchConnectivityError.notActivated
}
try WCSession.default.updateApplicationContext(context)
}
/// Send message for immediate delivery (both apps must be active).
func sendMessage(
_ message: [String: Any],
replyHandler: (([String: Any]) -> Void)? = nil,
errorHandler: ((Error) -> Void)? = nil
) {
guard isReachable else {
errorHandler?(WatchConnectivityError.notReachable)
return
}
WCSession.default.sendMessage(
message,
replyHandler: replyHandler,
errorHandler: errorHandler
)
}
/// Transfer user info (queued, guaranteed delivery).
@discardableResult
func transferUserInfo(_ userInfo: [String: Any]) -> WCSessionUserInfoTransfer? {
guard activationState == .activated else { return nil }
return WCSession.default.transferUserInfo(userInfo)
}
/// Transfer file (background, for large data).
@discardableResult
func transferFile(_ file: URL, metadata: [String: Any]?) -> WCSessionFileTransfer? {
guard activationState == .activated else { return nil }
return WCSession.default.transferFile(file, metadata: metadata)
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor in
self.activationState = activationState
self.isReachable = session.isReachable
#if os(iOS)
self.isPaired = session.isPaired
self.isWatchAppInstalled = session.isWatchAppInstalled
#endif
if let error {
print("⌚ Activation error: \(error)")
} else {
print("⌚ Activated: \(activationState.rawValue)")
}
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor in
self.isReachable = session.isReachable
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
print("⌚ Session became inactive")
}
func sessionDidDeactivate(_ session: WCSession) {
print("⌚ Session deactivated, reactivating...")
WCSession.default.activate()
}
func sessionWatchStateDidChange(_ session: WCSession) {
Task { @MainActor in
self.isPaired = session.isPaired
self.isWatchAppInstalled = session.isWatchAppInstalled
}
}
#endif
// MARK: - Receiving Data
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
Task { @MainActor in
self.onContextReceived?(applicationContext)
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Task { @MainActor in
self.onMessageReceived?(message)
}
}
func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
Task { @MainActor in
self.onMessageReceived?(message)
// Send reply
replyHandler(["status": "received"])
}
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
Task { @MainActor in
self.onUserInfoReceived?(userInfo)
}
}
func session(_ session: WCSession, didReceive file: WCSessionFile) {
// Handle received file
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(file.fileURL.lastPathComponent)
do {
try FileManager.default.copyItem(at: file.fileURL, to: destinationURL)
print("⌚ File received: \(destinationURL)")
} catch {
print("⌚ File copy error: \(error)")
}
}
}
// MARK: - Errors
enum WatchConnectivityError: LocalizedError {
case notSupported
case notActivated
case notReachable
case notPaired
case watchAppNotInstalled
var errorDescription: String? {
switch self {
case .notSupported: return "Watch Connectivity is not supported"
case .notActivated: return "Session is not activated"
case .notReachable: return "Companion app is not reachable"
case .notPaired: return "No Watch is paired"
case .watchAppNotInstalled: return "Watch app is not installed"
}
}
}Data Transfer Patterns
Sync Settings
// iPhone side
struct SettingsSync {
static func syncToWatch() throws {
let settings: [String: Any] = [
"theme": UserDefaults.standard.string(forKey: "theme") ?? "system",
"notifications": UserDefaults.standard.bool(forKey: "notifications"),
"syncDate": Date().timeIntervalSince1970
]
try WatchConnectivityManager.shared.updateContext(settings)
}
}
// Watch side
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
if let theme = context["theme"] as? String {
UserDefaults.standard.set(theme, forKey: "theme")
}
if let notifications = context["notifications"] as? Bool {
UserDefaults.standard.set(notifications, forKey: "notifications")
}
}Real-Time Actions
// Watch: Request data from iPhone
func requestLatestData() {
WatchConnectivityManager.shared.sendMessage(
["action": "refresh"],
replyHandler: { response in
if let items = response["items"] as? [[String: Any]] {
self.updateItems(items)
}
},
errorHandler: { error in
print("Failed to get data: \(error)")
}
)
}
// iPhone: Respond to request
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
if message["action"] as? String == "refresh" {
let items = DataStore.shared.items.map { $0.toDictionary() }
replyHandler(["items": items])
}
}Queued Updates
// iPhone: Queue update for Watch
func queueItemUpdate(_ item: Item) {
let update: [String: Any] = [
"type": "itemUpdate",
"item": item.toDictionary(),
"timestamp": Date().timeIntervalSince1970
]
WatchConnectivityManager.shared.transferUserInfo(update)
}
// Watch: Process queued updates
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
guard let type = userInfo["type"] as? String else { return }
switch type {
case "itemUpdate":
if let itemData = userInfo["item"] as? [String: Any],
let item = Item(dictionary: itemData) {
DataStore.shared.updateItem(item)
}
default:
break
}
}Offline Support
Caching Received Data
@Observable
final class WatchDataCache {
private let defaults = UserDefaults.standard
private let cacheKey = "watchDataCache"
var cachedItems: [Item] {
get {
guard let data = defaults.data(forKey: cacheKey),
let items = try? JSONDecoder().decode([Item].self, from: data) else {
return []
}
return items
}
set {
let data = try? JSONEncoder().encode(newValue)
defaults.set(data, forKey: cacheKey)
}
}
func updateFromContext(_ context: [String: Any]) {
if let itemsData = context["items"] as? Data,
let items = try? JSONDecoder().decode([Item].self, from: itemsData) {
cachedItems = items
}
}
}Handling Disconnection
struct WatchSafeView: View {
let connectivity = WatchConnectivityManager.shared
var body: some View {
Group {
if connectivity.isReachable {
LiveDataView()
} else {
CachedDataView()
.overlay(alignment: .top) {
Text("Offline")
.font(.caption)
.padding(4)
.background(.yellow)
.clipShape(Capsule())
}
}
}
}
}Complication Updates
Push Updates from iPhone
// iPhone side
import ClockKit
func updateWatchComplication() {
#if os(iOS)
guard WCSession.default.isComplicationEnabled else { return }
// Send update via complicationUserInfo (limited to 50/day)
let info: [String: Any] = [
"complicationData": latestData,
"updateTime": Date().timeIntervalSince1970
]
WCSession.default.transferCurrentComplicationUserInfo(info)
#endif
}
// Watch side
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
if userInfo["complicationData"] != nil {
// Reload complications
let server = CLKComplicationServer.sharedInstance()
for complication in server.activeComplications ?? [] {
server.reloadTimeline(for: complication)
}
}
}Testing
Simulator Testing
1. Run iPhone app on iPhone Simulator 2. Run Watch app on paired Watch Simulator 3. Messages and context work between paired simulators
Debug Logging
extension WatchConnectivityManager {
func logState() {
print("""
⌚ Watch Connectivity State:
Activation: \(activationState.rawValue)
Reachable: \(isReachable)
""")
#if os(iOS)
print("""
Paired: \(isPaired)
Watch App Installed: \(isWatchAppInstalled)
Complication Enabled: \(WCSession.default.isComplicationEnabled)
""")
#endif
}
}Best Practices
1. Don't rely on connectivity - App should work offline 2. Use appropriate transfer method - Context for state, messages for real-time 3. Handle activation states - Check before sending 4. Debounce updates - Don't spam context updates 5. Serialize properly - Only plist-compatible types 6. Test both directions - iPhone → Watch and Watch → iPhone
Widgets for watchOS
watchOS-specific widget patterns for Smart Stack, relevance, and cross-platform widget sharing.
watchOS Widget Families
| Family | Shape | Size | Best For |
|---|---|---|---|
.accessoryCircular | Circle | Small | Single metric, gauge, icon |
.accessoryRectangular | Rectangle | Medium | Multi-line text, small charts |
.accessoryCorner | Curved | Small | Corner gauge with label |
.accessoryInline | Line | Text only | Single line of text |
These families are shared with iOS Lock Screen widgets. Code can be reused across platforms.
Smart Stack Widgets (watchOS 10+)
Smart Stack is a scrollable stack of widgets accessible from the watch face by turning the Digital Crown. Widgets here can be larger than complications.
Smart Stack Configuration
import WidgetKit
import SwiftUI
struct DailyProgressEntry: TimelineEntry {
let date: Date
let steps: Int
let calories: Int
let exerciseMinutes: Int
}
struct DailyProgressProvider: TimelineProvider {
func placeholder(in context: Context) -> DailyProgressEntry {
DailyProgressEntry(date: .now, steps: 0, calories: 0, exerciseMinutes: 0)
}
func getSnapshot(in context: Context, completion: @escaping (DailyProgressEntry) -> Void) {
completion(DailyProgressEntry(date: .now, steps: 6500, calories: 280, exerciseMinutes: 22))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<DailyProgressEntry>) -> Void) {
let entry = DailyProgressEntry(
date: .now,
steps: DataStore.shared.todaySteps,
calories: DataStore.shared.todayCalories,
exerciseMinutes: DataStore.shared.todayExerciseMinutes
)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct DailyProgressWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "DailyProgress",
provider: DailyProgressProvider()
) { entry in
DailyProgressView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Daily Progress")
.description("Track your daily activity goals.")
.supportedFamilies([
.accessoryRectangular,
.accessoryCircular,
.accessoryInline
])
}
}Smart Stack View
struct DailyProgressView: View {
@Environment(\.widgetFamily) var family
let entry: DailyProgressEntry
var body: some View {
switch family {
case .accessoryRectangular:
RectangularProgressView(entry: entry)
case .accessoryCircular:
CircularProgressView(entry: entry)
case .accessoryInline:
Text("\(entry.steps) steps - \(entry.calories) cal")
default:
Text("\(entry.steps)")
}
}
}
struct RectangularProgressView: View {
let entry: DailyProgressEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Label("\(entry.steps)", systemImage: "figure.walk")
Spacer()
Label("\(entry.calories)", systemImage: "flame.fill")
}
.font(.caption2)
ProgressView(value: Double(entry.exerciseMinutes), total: 30) {
Text("\(entry.exerciseMinutes)/30 min")
.font(.caption2)
}
}
}
}
struct CircularProgressView: View {
let entry: DailyProgressEntry
var body: some View {
Gauge(value: Double(entry.steps), in: 0...10000) {
Image(systemName: "figure.walk")
} currentValueLabel: {
Text("\(entry.steps / 1000)k")
.font(.system(.body, design: .rounded))
}
.gaugeStyle(.accessoryCircularCapacity)
}
}Widget Relevance
Relevance determines when your widget appears at the top of the Smart Stack. Higher relevance scores surface the widget more prominently.
TimelineEntryRelevance
struct RelevantEntry: TimelineEntry {
let date: Date
let value: Int
let relevance: TimelineEntryRelevance?
}
struct RelevantProvider: TimelineProvider {
func placeholder(in context: Context) -> RelevantEntry {
RelevantEntry(date: .now, value: 0, relevance: nil)
}
func getSnapshot(in context: Context, completion: @escaping (RelevantEntry) -> Void) {
completion(RelevantEntry(date: .now, value: 42, relevance: nil))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RelevantEntry>) -> Void) {
var entries: [RelevantEntry] = []
// Morning: high relevance for weather
let morning = Calendar.current.date(bySettingHour: 7, minute: 0, second: 0, of: .now)!
entries.append(RelevantEntry(
date: morning,
value: 72,
relevance: TimelineEntryRelevance(score: 80)
))
// Midday: lower relevance
let midday = Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: .now)!
entries.append(RelevantEntry(
date: midday,
value: 85,
relevance: TimelineEntryRelevance(score: 30)
))
completion(Timeline(entries: entries, policy: .atEnd))
}
}Relevance Score Guidelines
| Score Range | When to Use |
|---|---|
| 0 | Default/no special relevance |
| 1-25 | Background information |
| 25-50 | Mildly relevant (routine data) |
| 50-75 | Relevant (approaching goal, upcoming event) |
| 75-100 | Highly relevant (active workout, imminent event) |
Tip: Assign high relevance during active moments (workout in progress, flight boarding soon) and low relevance during idle periods.
Widget Suggestions (watchOS 10+)
Proactively suggest widgets to users who haven't added them yet.
import WidgetKit
// Suggest your widget for Smart Stack
func suggestWidget() {
let suggestion = WidgetRecommendation(
kind: "DailyProgress",
description: "Track your daily activity."
)
WidgetCenter.shared.setRecommendations([suggestion])
}watchOS Design Considerations
Dark Background
watchOS widgets always render on a dark background. Design accordingly.
struct WatchWidgetView: View {
var body: some View {
VStack {
Text("Steps")
.foregroundStyle(.secondary) // Automatically lighter on dark
Text("8,432")
.font(.title3.bold())
.foregroundStyle(.white) // High contrast on dark
}
}
}Screen Size Constraints
Apple Watch screens are 40-49mm. Keep content minimal.
- Circular: 1 number or icon, optionally with gauge
- Rectangular: 2-3 lines maximum
- Inline: Under 20 characters
- Corner: Short label + gauge arc
Widget Container Background
// watchOS 10+ requires containerBackground
struct MyWidgetView: View {
let entry: MyEntry
var body: some View {
Text("\(entry.value)")
.containerBackground(.fill.tertiary, for: .widget)
}
}Rendering Modes
struct AdaptiveView: View {
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
switch renderingMode {
case .fullColor:
// Full color rendering (Modular Duo face, Smart Stack)
Image(systemName: "heart.fill")
.foregroundStyle(.red)
case .accented:
// Two-tone: accented elements + desaturated rest
Image(systemName: "heart.fill")
.widgetAccentable()
case .vibrant:
// Desaturated, system applies vibrancy
Image(systemName: "heart.fill")
@unknown default:
Image(systemName: "heart.fill")
}
}
}Cross-Platform Widget Sharing
Share widget code between iOS Lock Screen and watchOS by using the same accessory families.
Shared WidgetBundle
@main
struct MyWidgets: WidgetBundle {
var body: some Widget {
StepsWidget()
CaloriesWidget()
#if os(iOS)
HomeScreenWidget() // iOS home screen only (systemSmall, systemMedium)
#endif
}
}Conditional Families
struct StepsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "Steps", provider: StepsProvider()) { entry in
StepsWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.supportedFamilies(supportedFamilies)
.configurationDisplayName("Steps")
}
private var supportedFamilies: [WidgetFamily] {
var families: [WidgetFamily] = [
.accessoryCircular,
.accessoryRectangular,
.accessoryInline
]
#if os(watchOS)
families.append(.accessoryCorner)
#endif
#if os(iOS)
families.append(contentsOf: [.systemSmall, .systemMedium])
#endif
return families
}
}Platform-Adaptive Views
struct StepsWidgetView: View {
@Environment(\.widgetFamily) var family
let entry: StepsEntry
var body: some View {
switch family {
case .accessoryCircular:
// Shared between iOS Lock Screen and watchOS
CircularStepsView(steps: entry.steps)
case .accessoryRectangular:
RectangularStepsView(steps: entry.steps)
case .accessoryInline:
Text("\(entry.steps) steps")
case .accessoryCorner:
// watchOS only
CornerStepsView(steps: entry.steps)
#if os(iOS)
case .systemSmall:
SmallStepsView(steps: entry.steps)
case .systemMedium:
MediumStepsView(steps: entry.steps)
#endif
default:
Text("\(entry.steps)")
}
}
}Shared Widget Extension Target
For maximum code reuse, create a single widget extension with multi-platform support:
1. In Xcode, add watchOS as a destination to your widget extension target 2. Use #if os(watchOS) / #if os(iOS) for platform-specific code 3. Share the TimelineProvider, TimelineEntry, and common views 4. Use conditional compilation only for platform-specific families and views
Data Sharing Between App and Widget
App Groups
// Shared container for app and widget extension
let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")
// Main app writes
sharedDefaults?.set(stepCount, forKey: "todaySteps")
WidgetCenter.shared.reloadTimelines(ofKind: "Steps")
// Widget reads
func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let steps = defaults?.integer(forKey: "todaySteps") ?? 0
let entry = StepsEntry(date: .now, steps: steps)
completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(900))))
}SwiftData / CoreData Shared Container
// Use the shared App Group container for the database
let container = try ModelContainer(
for: ActivityRecord.self,
configurations: ModelConfiguration(
url: FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.myapp")!
.appendingPathComponent("ActivityData.store")
)
)Best Practices
1. Budget timeline reloads -- watchOS limits background execution. Request reloads no more than 4 times per hour.
2. Use relevance scores -- Set appropriate TimelineEntryRelevance scores so your widget surfaces at the right time in Smart Stack.
3. Design for glanceability -- Users glance at widgets for 1-2 seconds. Show the most important number or status prominently.
4. Test all rendering modes -- Preview your widget in .fullColor, .accented, and .vibrant modes using Xcode previews.
5. Provide meaningful placeholders -- Placeholders appear while the widget loads. Use realistic shapes and placeholder text, not empty views.
6. Keep timeline entries small -- Each entry is stored in memory. Don't embed large images or data in timeline entries.
7. Use `.containerBackground` -- Required on watchOS 10+. Omitting it causes a runtime warning and default background.
8. Support `.widgetAccentable()` -- Mark key visual elements so they adopt the watch face accent color in accented rendering mode.
Related skills
FAQ
What platforms does the watchos skill cover?
watchos covers Apple Watch app and complication development with SwiftUI and WatchKit, plus health and sensor APIs and iPhone companion synchronization following watchOS platform constraints from claude-code-apple-skills.
When should developers use watchos during a build?
watchos fits when an iOS product adds a watch target, watch face complications, or wrist-based health and sensor features. Use it for SwiftUI watch UI and companion sync rather than generic mobile web layouts.