Most Apple Watch tutorials show you the happy path — a single SwiftUI view, some WKExtensionDelegate boilerplate, and a screenshot of a glowing wrist. What they skip is the part that actually takes time: sharing code between targets without losing your mind, keeping two UIs in sync when the network is unreliable, and making architectural decisions that don't haunt you six months later.
I built a soccer game manager app — iPhone tab bar, Watch companion, real-time score and timer sync — and this is what I learned.
The Setup: Three Targets, No Shared Framework
The project has two shipping targets:
- Soccer Game Manager (iOS) — the full app with SwiftData persistence, a substitutions tracker, and team management
- Soccer Game Manager Watch App (watchOS) — a focused companion that shows score and timer, and lets you increment goals from your wrist
No shared Swift Package, no framework target. Instead, I used Xcode's file system synchronization with build exceptions — a feature quietly added in Xcode 16 that lets you include the same source files across targets but opt specific files out at the build level. The project file's PBXFileSystemSynchronizedBuildFileExceptionSet lists which files each target skips.
This works, but it's non-intuitive. Files appear once in the project navigator, yet they may or may not compile depending on which target you're building. If you're reading this before you start: a shared Swift Package is cleaner. I'd reach for that first. The build-exceptions approach is a viable workaround, not a pattern to emulate.
Pain Point #1: Conditional Compilation Proliferates
The first thing that starts growing when you share files between platforms is #if os(iOS) / #if os(watchOS). It starts innocently:
// GameTimerModel.swift
#if os(watchOS)
import WatchKit
#else
import UIKit
#endif
Then it shows up in your model initializer:
// WCSessionCoordinator.swift
#if os(iOS)
init(scoreModel: GameScoreModel, timerModel: GameTimerModel, playTimeTracker: PlayTimeTracker) {
// ...
}
#else
init(scoreModel: GameScoreModel, timerModel: GameTimerModel) {
// Watch doesn't have PlayTimeTracker
}
#endif
Then it shows up in haptic feedback, in save logic, in feature flags. Each individual use is reasonable. Together, they make it increasingly hard to reason about what any given file actually does on a specific platform.
The lesson: treat #if os() like you treat ! force-unwraps — one or two in the right place is fine, accumulation is a code smell. If a file has more than one or two platform branches, it probably wants to be split into platform-specific implementations behind a shared protocol.
Pain Point #2: WatchConnectivity Is Fire-and-Forget (and That's Okay, If You Plan for It)
WCSession gives you two primary messaging tools:
transferUserInfo(_:)— queued, delivered in background, no timing guaranteeupdateApplicationContext(_:)— replaces the previous context; Watch reads it at launch
The instinct is to treat transferUserInfo like a remote function call. It isn't. If the Watch is in the pocket when you score a goal, that message will arrive... eventually. When the Watch wakes up, it'll process queued userInfo transfers in order. But if you restarted the app in the meantime, your in-memory state is already stale.
My solution was two-layer sync:
- Live updates via
transferUserInfo— fast, for changes that just happened - Full-state catchup via
updateApplicationContext— an iPhone-side dictionary that accumulates every state key, so a Watch cold-launching mid-game can reconstruct the full picture immediately
// TransferManager.swift — iPhone side
// Keeps a running context dict so Watch can catch up on launch
private var cachedContext: [String: Any] = [:]
func send(_ message: WatchTransferMessage) {
let encoded = message.encoded()
cachedContext.merge(encoded) { _, new in new }
try? session.updateApplicationContext(cachedContext)
session.transferUserInfo(encoded)
}
And on the Watch side, on app launch:
// WCSessionCoordinator.swift — Watch side
func applyPendingApplicationContext() {
guard let context = session.receivedApplicationContext,
!context.isEmpty else { return }
// Replay full state into models
handleMessages(context)
}
This pattern — live diffs plus a full-state snapshot — is the right model for WatchConnectivity. Don't rely on either alone.
What Worked: Type-Safe Message Passing
The part I'm most happy with is the message protocol. Instead of passing raw [String: Any] dictionaries back and forth, I defined a WatchTransferMessage enum with associated values:
enum WatchTransferMessage {
case scoreUpdate(team: Team, score: Int)
case teamNameUpdate(team: Team, name: String)
case timerState(action: TimerAction, elapsed: TimeInterval, half: Int, duration: TimeInterval, startDate: Date?)
}
Each case encodes itself to a dictionary (for WCSession) and decodes back from one. The encoding/decoding is the only place where stringly-typed keys appear. Everywhere else in the app, you work with the strongly-typed enum.
This makes the coordinator clean:
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
Task { @MainActor in
guard let message = WatchTransferMessage(from: userInfo) else { return }
switch message {
case .scoreUpdate(let team, let score):
scoreModel.set(score: score, for: team)
case .timerState(let action, let elapsed, let half, let duration, let startDate):
timerModel.apply(action: action, elapsed: elapsed, ...)
// ...
}
}
}
If you add a new message type, the compiler tells you exactly which switch statements are non-exhaustive. That's worth the boilerplate.
The UI Split: Give the Watch What It Needs, Nothing More
The iPhone app has three tabs: Substitutions, Timer, Score. The Watch has two: Timer, Score. That's the right call.
The Watch UI isn't a smaller iPhone UI — it's a different context. You glance at your wrist to increment a goal or check the time remaining. You don't manage substitutions from your wrist mid-game. Trying to port the full iPhone UI to watchOS produces something technically correct and practically useless.
Some views did reuse successfully — ScoreStepperView, a team-agnostic score control, compiles on both platforms without changes. That's the bar for shared UI: if a view needs platform branches to render, it probably wants to be two views.
The Timer Sync Problem
Timers are surprisingly hard to synchronize. A TimeInterval sent at time T is stale by the time it arrives. The fix is to send a startDate instead of (or alongside) elapsed time:
case .timerState(action: .start, elapsed: elapsed, half: half,
duration: duration, startDate: Date())
The Watch receives this and computes elapsed time as Date().timeIntervalSince(startDate) + elapsed. This way, delivery delay doesn't matter — the Watch reconstructs the correct position on receipt regardless of when the message arrived.
What I'd Do Differently
Use a shared Swift Package. The build-exception approach avoids the overhead of creating a package, but the cost is subtle: the file-to-target membership is invisible in the project navigator, conditional compilation grows, and adding a third target gets messy fast. A GameCore package with your models, messaging protocol, and storage interfaces would have been cleaner.
Structured concurrency for WatchConnectivity. The current code wraps WCSessionDelegate callbacks in Task { @MainActor in } blocks, which works but isn't structured. An AsyncStream<WatchTransferMessage> would give you backpressure, cancellation, and a cleaner consumption site.
The Real Lesson
Building for Apple Watch alongside iPhone isn't primarily a UI problem — it's a state synchronization problem. Get that wrong and the right visual solution can't save you. Get it right and the UI work is straightforward.
The pattern that works: define your messages as strongly-typed values, send live diffs for immediacy, maintain a full-state snapshot for cold launches, and account for delivery delay in time-sensitive state like timers. Everything else is details.