When people build iOS and watchOS apps together, they usually start fast and copy code between targets. It works for a week, then every bug fix has to be done twice. Two weeks later, it feels like you are carrying two apps that look similar but behave differently.
I like to explain this with a restaurant analogy.
- The kitchen is your shared logic.
- The dining room and takeaway window are your platform-specific UIs.
You want one kitchen, not two separate kitchens cooking the same meal differently.
Step 1: Move business language into shared types
Start by defining a shared model that both platforms can understand. Keep it boring and explicit.
struct SessionSummary: Equatable {
let minutesToday: Int
let currentStreak: Int
let lastUpdatedAt: Date
}
This is your shared language. No UI code yet. No platform code yet.
Step 2: Hide data access behind a protocol
If both apps talk directly to storage or network code, you will duplicate error handling immediately.
protocol SessionStore {
func loadSummary() async throws -> SessionSummary
func logSession(minutes: Int) async throws
}
Now both iOS and watchOS can depend on the same interface.
Step 3: Build one view model for both platforms
Think of this as your teacher in the classroom. The teacher gives the lesson. The whiteboard style can change, but the lesson should not.
@MainActor
final class SessionViewModel: ObservableObject {
@Published private(set) var title = "Loading..."
@Published private(set) var isBusy = false
@Published private(set) var errorMessage: String?
private let store: SessionStore
init(store: SessionStore) {
self.store = store
}
func refresh() async {
isBusy = true
defer { isBusy = false }
do {
let summary = try await store.loadSummary()
title = "\(summary.minutesToday) min today | streak \(summary.currentStreak)"
errorMessage = nil
} catch {
errorMessage = "Could not load session summary."
}
}
}
At this point, your behavior is centralized. That is the hardest and most valuable part.
Step 4: Keep each platform UI thin
Now you can build iOS and watchOS screens that simply render shared state.
struct PhoneDashboardView: View {
@StateObject var vm: SessionViewModel
var body: some View {
VStack(spacing: 12) {
Text(vm.title)
if let message = vm.errorMessage {
Text(message).foregroundColor(.red)
}
Button("Refresh") {
Task { await vm.refresh() }
}
}
.task { await vm.refresh() }
}
}
struct WatchDashboardView: View {
@StateObject var vm: SessionViewModel
var body: some View {
VStack {
Text(vm.title).font(.headline)
Button("Reload") {
Task { await vm.refresh() }
}
}
.task { await vm.refresh() }
}
}
Different presentation, same behavior. That is exactly what you want.
Step 5: Teach your architecture with tests
If architecture cannot be tested quickly, it is not stable.
struct FakeStore: SessionStore {
var summary: SessionSummary
func loadSummary() async throws -> SessionSummary { summary }
func logSession(minutes: Int) async throws {}
}
Use fake stores to prove your view model logic before UI polishing.
A quick implementation checklist
- Shared model and protocol first.
- Shared view model second.
- Platform views third.
- Fake-store tests before feature expansion.
If you follow this order, your iPhone and Apple Watch app can grow without becoming a copy-paste maintenance trap.