Build a Cross-Platform App: Sharing Code Across iOS, iPadOS, macOS, and watchOS
Imagine writing a film browser once and shipping it to four devices — the iPhone in someone’s pocket, the iPad on their desk, the Mac they use for work, and the Apple Watch on their wrist. That’s not a distant dream — it’s exactly what SwiftUI’s multiplatform support makes possible today.
In this tutorial, you’ll build Pixar Film Vault — a single codebase app that lets users browse Pixar films, read synopses, and save favorites across iPhone, iPad, Mac, and Apple Watch. Along the way, you’ll learn how to structure shared code using a local Swift package, adapt navigation layouts per platform, use conditional compilation to handle platform-specific APIs, and synchronize data across devices with CloudKit.
Prerequisites
- Xcode 16+ with iOS 18, macOS 15, and watchOS 11 deployment targets
- Familiarity with SwiftUI state management
- Familiarity with navigation patterns
- Familiarity with SwiftData
Contents
- Getting Started
- Step 1: Shared Models in a Local Package
- Step 2: Building the iPhone Layout
- Step 3: Adapting for iPad
- Step 4: Adding macOS-Specific Features
- Step 5: Platform-Specific View Variants
- Step 6: Building the watchOS Companion
- Step 7: Conditional Compilation and Shared Components
- Step 8: CloudKit Sync Across All Platforms
- Where to Go From Here?
Getting Started
When Pixar makes a film, every department — story, animation, lighting, sound — works from a single shared asset pipeline. The final film plays in theaters, on screens large and small, and on streaming devices, all from that one source. Your multiplatform app works the same way: one source of truth, many rendering contexts.
Creating the Multiplatform Project
Open Xcode and create a new project:
- Choose File > New > Project.
- Select the Multiplatform tab, then choose App.
- Set the Product Name to
PixarFilmVault. - Set Organization Identifier to your reverse-domain identifier (e.g.,
com.yourname). - Choose Swift as the language and SwiftUI as the interface.
- Click Next, choose a location, and click Create.
Xcode creates a project with a shared PixarFilmVault target that includes iOS by default. You need to add macOS and
watchOS destinations.
Adding Destinations
In the Project Navigator, select the PixarFilmVault project, then select the PixarFilmVault target:
- Under General > Supported Destinations, click the + button.
- Add macOS as a destination.
- Click + again and add watchOS App — Xcode will prompt you to add a companion watchOS target named
PixarFilmVault Watch App.
Your project now has three executable targets sharing code in the PixarFilmVault group.
Configuring Deployment Targets
Set minimum deployment targets that support the APIs you’ll use:
- iOS / iPadOS: 18.0
- macOS: 15.0
- watchOS: 11.0
Select each target in turn under General > Minimum Deployments and set the appropriate version.
Configuring SwiftData
Before writing any code, enable the iCloud capability that SwiftData’s CloudKit integration will need later. Select the
PixarFilmVault target, click the Signing & Capabilities tab, and click + Capability. Add iCloud and enable
the CloudKit checkbox. Xcode will create a container identifier that looks like iCloud.com.yourname.PixarFilmVault
— note this value, you’ll use it in Step 8.
Repeat this for the watchOS target.
Note: CloudKit sync requires a paid Apple Developer account. You can follow the entire tutorial without it and skip Step 8 if you prefer to test locally only.
Step 1: Shared Models in a Local Package
A common mistake when starting multiplatform apps is putting all shared code directly in the app target. This works
initially, but as the codebase grows, you end up with a tangle of #if os() checks scattered across files that are
conceptually separate from platform-specific UI code. The cleaner approach — the one used by teams shipping on all Apple
platforms — is to extract shared logic into a local Swift package.
A local package has three key advantages over a shared target group:
- Single source of truth. Every platform target imports the same module — there is no risk of copy-paste drift.
- Faster incremental builds. Xcode can cache the package independently of each platform target, so a change to your macOS toolbar doesn’t trigger a recompile of your watch extension.
- Explicit boundaries. The package’s public API defines exactly what is shared. Platform-specific concerns live in the targets that own them.
Creating the FilmKit Package
In Xcode, choose File > New > Package. Name it FilmKit and save it inside your project folder (next to
PixarFilmVault.xcodeproj). In the dialog that appears, make sure Add to: PixarFilmVault and Group:
PixarFilmVault are selected so Xcode integrates it automatically.
Xcode creates FilmKit/Sources/FilmKit/FilmKit.swift. Delete that placeholder file — you’ll replace it with your own
modules.
Adding FilmKit to All Targets
Select each of your three targets (iOS, macOS, watchOS), navigate to General > Frameworks, Libraries, and Embedded
Content, click +, and add FilmKit to each one.
Defining the PixarFilm Model
Create a new Swift file at FilmKit/Sources/FilmKit/PixarFilm.swift. This is the single definition of your data model,
shared across all four platforms:
import Foundation
import SwiftData
@Model
public final class PixarFilm {
public var title: String
public var year: Int
public var director: String
public var synopsis: String
public var isFavorite: Bool
public var thumbnailURL: String
public init(
title: String,
year: Int,
director: String,
synopsis: String,
thumbnailURL: String,
isFavorite: Bool = false
) {
self.title = title
self.year = year
self.director = director
self.synopsis = synopsis
self.thumbnailURL = thumbnailURL
self.isFavorite = isFavorite
}
}
The @Model macro from SwiftData turns this class into a persisted entity. The public access modifier is required
because this type will be imported by external targets — Swift packages enforce access control at module boundaries.
Apple Docs:
@Model— SwiftData
Adding Sample Data
Create FilmKit/Sources/FilmKit/SampleData.swift to provide a set of Pixar films for previews and the simulator:
import Foundation
public extension PixarFilm {
static let sampleFilms: [PixarFilm] = [
PixarFilm(
title: "Toy Story",
year: 1995,
director: "John Lasseter",
synopsis: "A cowboy doll is profoundly threatened when a new spaceman toy supplants him as top toy in a boy's room.",
thumbnailURL: "https://example.com/toystory.jpg"
),
PixarFilm(
title: "Finding Nemo",
year: 2003,
director: "Andrew Stanton",
synopsis: "After his son is taken by a diver, an overprotective clownfish sets out on a journey across the ocean.",
thumbnailURL: "https://example.com/nemo.jpg"
),
PixarFilm(
title: "The Incredibles",
year: 2004,
director: "Brad Bird",
synopsis: "A family of undercover superheroes tries to live a quiet suburban life while struggling with the temptation to use their powers.",
thumbnailURL: "https://example.com/incredibles.jpg"
),
PixarFilm(
title: "WALL-E",
year: 2008,
director: "Andrew Stanton",
synopsis: "A small waste-collecting robot inadvertently embarks on a space journey that will determine the fate of mankind.",
thumbnailURL: "https://example.com/walle.jpg"
),
PixarFilm(
title: "Up",
year: 2009,
director: "Pete Docter",
synopsis: "78-year-old Carl Fredricksen travels to Paradise Falls in his house equipped with balloons, and brings a young stowaway named Russell.",
thumbnailURL: "https://example.com/up.jpg"
),
PixarFilm(
title: "Inside Out",
year: 2015,
director: "Pete Docter",
synopsis: "After young Riley is uprooted from her Midwest life and moved to San Francisco, her emotions struggle to adjust to the new city.",
thumbnailURL: "https://example.com/insideout.jpg"
),
PixarFilm(
title: "Coco",
year: 2017,
director: "Lee Unkrich",
synopsis: "A twelve-year-old aspiring musician crosses into the Land of the Dead to find his great-great-grandfather.",
thumbnailURL: "https://example.com/coco.jpg"
),
PixarFilm(
title: "Soul",
year: 2020,
director: "Pete Docter",
synopsis: "A musician who has lost his passion for music gets a chance to live his dreams — and discovers what it truly means to have a soul.",
thumbnailURL: "https://example.com/soul.jpg"
),
]
}
Configuring the ModelContainer
In the main app file (PixarFilmVault/PixarFilmVaultApp.swift), configure a shared ModelContainer that every platform
will use:
import SwiftUI
import SwiftData
import FilmKit
@main
struct PixarFilmVaultApp: App {
let container: ModelContainer
init() {
let schema = Schema([PixarFilm.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
container = try ModelContainer(for: schema, configurations: [config])
// Pre-populate with sample data if the store is empty
let context = ModelContext(container)
let existing = try context.fetch(FetchDescriptor<PixarFilm>())
if existing.isEmpty {
PixarFilm.sampleFilms.forEach { context.insert($0) }
try context.save()
}
} catch {
fatalError("Failed to configure ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
Apple Docs:
ModelContainer— SwiftDataCheckpoint: Build for an iOS Simulator target. The project should compile without errors. Xcode’s build log will show FilmKit compiled as a separate module before the app target — that’s the build caching benefit in action.
Step 2: Building the iPhone Layout
With the model in place, it’s time to build the iPhone experience. On iPhone, screen space is a premium, so the standard
pattern is a NavigationStack with a list that
drills into a detail view.
Creating FilmListView
Create PixarFilmVault/Views/FilmListView.swift:
import SwiftUI
import SwiftData
import FilmKit
struct FilmListView: View {
@Query(sort: \PixarFilm.year) private var films: [PixarFilm]
@State private var searchText = ""
private var filteredFilms: [PixarFilm] {
guard !searchText.isEmpty else { return films }
return films.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.director.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
List(filteredFilms) { film in
NavigationLink(value: film) {
FilmRow(film: film)
}
}
.searchable(text: $searchText, prompt: "Search films or directors")
.navigationTitle("Pixar Film Vault")
.navigationDestination(for: PixarFilm.self) { film in
FilmDetailView(film: film)
}
}
}
The @Query macro fetches all PixarFilm objects from the SwiftData store and sorts them by release year. Any changes
to the store — including favorites toggled on another platform — automatically update this view.
Creating FilmRow
Create PixarFilmVault/Views/FilmRow.swift:
import SwiftUI
import FilmKit
struct FilmRow: View {
let film: PixarFilm
var body: some View {
HStack(spacing: 12) {
AsyncImage(url: URL(string: film.thumbnailURL)) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure, .empty:
Image(systemName: "film")
.font(.title2)
.foregroundStyle(.secondary)
@unknown default:
ProgressView()
}
}
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 8))
.background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(film.title)
.font(.headline)
Text("\(film.year) · \(film.director)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if film.isFavorite {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
}
}
.padding(.vertical, 4)
}
}
Creating FilmDetailView
Create PixarFilmVault/Views/FilmDetailView.swift:
import SwiftUI
import FilmKit
struct FilmDetailView: View {
@Bindable var film: PixarFilm
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Hero image
AsyncImage(url: URL(string: film.thumbnailURL)) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Rectangle()
.fill(Color.secondary.opacity(0.15))
.overlay(Image(systemName: "film").font(.largeTitle))
}
}
.frame(maxWidth: .infinity)
.frame(height: 240)
.clipped()
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading) {
Text(film.title)
.font(.title.bold())
Text("\(film.year) · Directed by \(film.director)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
FavoriteButton(film: film)
}
Divider()
Text(film.synopsis)
.font(.body)
.lineSpacing(4)
}
.padding(.horizontal)
}
}
.navigationTitle(film.title)
#if !os(macOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
Creating the FavoriteButton
The FavoriteButton component will be reused across all platforms, so create it in a shared location at
PixarFilmVault/Components/FavoriteButton.swift:
import SwiftUI
import FilmKit
struct FavoriteButton: View {
@Bindable var film: PixarFilm
@Environment(\.modelContext) private var context
var body: some View {
Button {
film.isFavorite.toggle()
try? context.save()
} label: {
Image(systemName: film.isFavorite ? "heart.fill" : "heart")
.font(.title2)
.foregroundStyle(film.isFavorite ? .red : .secondary)
.symbolEffect(.bounce, value: film.isFavorite)
}
.buttonStyle(.plain)
.accessibilityLabel(film.isFavorite ? "Remove from favorites" : "Add to favorites")
}
}
The @Bindable property wrapper lets you mutate the SwiftData model object directly from the view. When isFavorite
changes, SwiftData notifies all views observing that object — including any @Query that depends on it.
Wiring Up ContentView for iPhone
Update PixarFilmVault/ContentView.swift to use NavigationStack for the compact (iPhone) layout:
import SwiftUI
import FilmKit
struct ContentView: View {
var body: some View {
NavigationStack {
FilmListView()
}
}
}
Checkpoint: Build and run on the iPhone 16 Simulator. You should see a scrollable list of Pixar films sorted by release year, with a search bar. Tapping a row navigates to the film’s detail page. Tapping the heart button on the detail page marks the film as a favorite, and a filled red heart appears in the row.
Step 3: Adapting for iPad
On iPhone, NavigationStack is the right choice — you have one column and you push views onto it. On iPad in landscape,
you have the real estate for a split-view layout that keeps the list and detail visible simultaneously. SwiftUI’s
NavigationSplitView handles this
automatically, and it degrades gracefully to a stack on compact-size-class devices like iPhone.
The key to making one ContentView work for both is @Environment(\.horizontalSizeClass).
Apple Docs:
NavigationSplitView— SwiftUI
Updating ContentView for Split Layout
Replace the contents of ContentView.swift with a layout that chooses between split and stack based on size class:
import SwiftUI
import FilmKit
struct ContentView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedCategory: FilmCategory? = .all
@State private var selectedFilm: PixarFilm?
var body: some View {
if horizontalSizeClass == .compact {
// iPhone and iPhone-sized multitasking on iPad
NavigationStack {
FilmListView(category: selectedCategory ?? .all,
selectedFilm: $selectedFilm)
}
} else {
// iPad in regular size class
NavigationSplitView {
CategorySidebar(selectedCategory: $selectedCategory)
} content: {
FilmListView(category: selectedCategory ?? .all,
selectedFilm: $selectedFilm)
} detail: {
if let film = selectedFilm {
FilmDetailView(film: film)
} else {
ContentUnavailableView(
"Select a Film",
systemImage: "film",
description: Text("Choose a film from the list to see its details.")
)
}
}
}
}
}
Adding the Category Sidebar
The sidebar lets users filter by era. Create PixarFilmVault/Views/CategorySidebar.swift:
import SwiftUI
import FilmKit
enum FilmCategory: String, CaseIterable, Identifiable {
case all = "All Films"
case nineties = "1990s"
case twoThousands = "2000s"
case twentyTens = "2010s"
case twentyTwenties = "2020s"
var id: String { rawValue }
var yearRange: ClosedRange<Int>? {
switch self {
case .all: return nil
case .nineties: return 1990...1999
case .twoThousands: return 2000...2009
case .twentyTens: return 2010...2019
case .twentyTwenties: return 2020...2029
}
}
}
struct CategorySidebar: View {
@Binding var selectedCategory: FilmCategory?
var body: some View {
List(FilmCategory.allCases, selection: $selectedCategory) { category in
Label(category.rawValue, systemImage: iconName(for: category))
.tag(category)
}
.navigationTitle("Browse")
.listStyle(.sidebar)
}
private func iconName(for category: FilmCategory) -> String {
switch category {
case .all: return "film.stack"
case .nineties: return "clock.badge.9"
case .twoThousands: return "clock.badge.0"
case .twentyTens: return "calendar.badge.clock"
case .twentyTwenties: return "sparkles"
}
}
}
Updating FilmListView to Accept a Category Filter
Update FilmListView to accept a category parameter and a binding for the selected film, which is needed for the
split-view coordination on iPad:
import SwiftUI
import SwiftData
import FilmKit
struct FilmListView: View {
let category: FilmCategory
@Binding var selectedFilm: PixarFilm?
@Query(sort: \PixarFilm.year) private var allFilms: [PixarFilm]
@State private var searchText = ""
private var filteredFilms: [PixarFilm] {
let categorized: [PixarFilm]
if let range = category.yearRange {
categorized = allFilms.filter { range.contains($0.year) }
} else {
categorized = allFilms
}
guard !searchText.isEmpty else { return categorized }
return categorized.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.director.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
List(filteredFilms, selection: $selectedFilm) { film in
FilmRow(film: film)
.tag(film)
}
.searchable(text: $searchText, prompt: "Search films or directors")
.navigationTitle(category.rawValue)
}
}
The selection parameter on List lets NavigationSplitView automatically show the selected film in the detail column
— no navigationDestination needed in split-view mode.
Tip:
NavigationSplitViewautomatically collapses into a stack on compact size classes. You don’t need to write separate logic for the iPhone layout once you’ve adopted the size class check inContentView.Checkpoint: Build and run on the iPad Pro (13-inch) Simulator. You should see a three-column layout: a sidebar listing film categories on the left, a film list in the center, and film details on the right when you tap a row. Rotate to portrait to see the sidebar collapse. Run again on the iPhone Simulator — the same
ContentViewrenders as a single-column stack.
Step 4: Adding macOS-Specific Features
Mac users expect native behaviors: toolbar buttons, keyboard shortcuts, and window management. SwiftUI gives you these
through .toolbar(content:) and the .commands scene modifier — but some features, like NSOpenPanel for file import,
require diving into AppKit with #if os(macOS).
Adding a macOS Toolbar
macOS places toolbar items in the window chrome above the content area, not inside the navigation bar. Update
PixarFilmVault/ContentView.swift to add a macOS-specific toolbar:
import SwiftUI
import FilmKit
struct ContentView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedCategory: FilmCategory? = .all
@State private var selectedFilm: PixarFilm?
@State private var isImportingFilms = false
var body: some View {
Group {
if horizontalSizeClass == .compact {
NavigationStack {
FilmListView(category: selectedCategory ?? .all,
selectedFilm: $selectedFilm)
}
} else {
NavigationSplitView {
CategorySidebar(selectedCategory: $selectedCategory)
} content: {
FilmListView(category: selectedCategory ?? .all,
selectedFilm: $selectedFilm)
} detail: {
if let film = selectedFilm {
FilmDetailView(film: film)
} else {
ContentUnavailableView(
"Select a Film",
systemImage: "film",
description: Text("Choose a film from the list.")
)
}
}
}
}
#if os(macOS)
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: toggleSidebar) {
Label("Toggle Sidebar", systemImage: "sidebar.leading")
}
}
ToolbarItem(placement: .primaryAction) {
Button {
isImportingFilms = true
} label: {
Label("Import Films", systemImage: "square.and.arrow.down")
}
.keyboardShortcut("n", modifiers: .command)
}
}
.sheet(isPresented: $isImportingFilms) {
FilmImportView()
}
#endif
}
#if os(macOS)
private func toggleSidebar() {
NSApp.keyWindow?.firstResponder?
.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
}
#endif
}
The #if os(macOS) compiler directives ensure that AppKit-only code — like NSApp and NSSplitViewController — is
only compiled when building for Mac. The iOS and watchOS targets never see this code.
Adding Keyboard Shortcuts via Scene Commands
App-level keyboard shortcuts belong in the Scene, not in views. Update PixarFilmVaultApp.swift to register commands:
import SwiftUI
import SwiftData
import FilmKit
@main
struct PixarFilmVaultApp: App {
let container: ModelContainer
init() {
// ... same as before ...
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
#if os(macOS)
.commands {
CommandGroup(after: .newItem) {
Button("Import Film Data...") {
NotificationCenter.default.post(
name: .importFilmData,
object: nil
)
}
.keyboardShortcut("n", modifiers: [.command, .shift])
}
}
#endif
}
}
#if os(macOS)
extension Notification.Name {
static let importFilmData = Notification.Name("importFilmData")
}
#endif
Creating FilmImportView for macOS
Create PixarFilmVault/Views/macOS/FilmImportView.swift. This view uses NSOpenPanel to let users import a JSON file
containing custom film data:
#if os(macOS)
import SwiftUI
import AppKit
import FilmKit
struct FilmImportView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@State private var importedFilms: [PixarFilm] = []
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 20) {
Text("Import Film Data")
.font(.title2.bold())
Text("Select a JSON file containing an array of Pixar film entries.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let error = errorMessage {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
HStack {
Button("Choose File...") {
openFilePicker()
}
.buttonStyle(.borderedProminent)
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.escape)
}
}
.padding(32)
.frame(minWidth: 400)
}
private func openFilePicker() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
if panel.runModal() == .OK, let url = panel.url {
importFilms(from: url)
}
}
private func importFilms(from url: URL) {
do {
let data = try Data(contentsOf: url)
// In a real app, decode your JSON into PixarFilm objects
// For now, we acknowledge the import was attempted
errorMessage = nil
dismiss()
} catch {
errorMessage = "Failed to read file: \(error.localizedDescription)"
}
}
}
#endif
Tip: Wrapping the entire file in
#if os(macOS)is cleaner than wrapping individual declarations when the entire file is platform-specific. Xcode will still show the file in the Project Navigator on all platforms, but only compile it for Mac.Checkpoint: Build and run on My Mac (Mac Catalyst) or the macOS destination. You should see the navigation split view with a toolbar at the top of the window. Press ⌘N to trigger the import sheet. The sidebar toggle button should slide the sidebar in and out. Pressing Escape in the import sheet should dismiss it.
Step 5: Platform-Specific View Variants
Some views look right on iPhone but feel cramped on Mac or oversized on Watch. Rather than duplicating entire views, use
#if os() blocks and ViewModifier to inject platform-specific adjustments into a shared structure.
The FilmCard Component
A FilmCard is a richer, grid-friendly representation of a film — used in the iPad grid view and macOS browse mode.
Create PixarFilmVault/Components/FilmCard.swift:
import SwiftUI
import FilmKit
struct FilmCard: View {
let film: PixarFilm
var body: some View {
VStack(alignment: .leading, spacing: 0) {
AsyncImage(url: URL(string: film.thumbnailURL)) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Rectangle()
.fill(Color.secondary.opacity(0.15))
.overlay(Image(systemName: "film").font(.title))
}
}
.frame(maxWidth: .infinity)
.frame(height: cardImageHeight)
.clipped()
VStack(alignment: .leading, spacing: 4) {
Text(film.title)
.font(.headline)
.lineLimit(2)
Text(String(film.year))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
}
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.08), radius: 6, y: 3)
.frame(maxWidth: cardMaxWidth)
}
private var cardImageHeight: CGFloat {
#if os(macOS)
return 180
#else
return 140
#endif
}
private var cardMaxWidth: CGFloat {
#if os(macOS)
return 240
#elseif os(iOS)
return 180
#else
return 120 // Simplified for clarity
#endif
}
}
A PlatformAdaptive ViewModifier
For stylistic adaptations — like adding a hover effect on macOS — a ViewModifier is cleaner than conditional blocks
scattered throughout views:
import SwiftUI
struct PlatformCardStyle: ViewModifier {
@State private var isHovered = false
func body(content: Content) -> some View {
content
#if os(macOS)
.scaleEffect(isHovered ? 1.03 : 1.0)
.animation(.spring(response: 0.2), value: isHovered)
.onHover { hovering in isHovered = hovering }
#endif
}
}
extension View {
func platformCardStyle() -> some View {
modifier(PlatformCardStyle())
}
}
Apply this modifier to FilmCard by adding .platformCardStyle() to the outermost VStack. On iOS and watchOS, the
modifier is a no-op that compiles away entirely. On macOS, it adds a subtle scale effect when the user hovers over a
card.
Apple Docs:
ViewModifier— SwiftUITip: Prefer
ViewModifierfor behavioral differences (hover, focus, keyboard), and#if os()for structural differences (different child views or significantly different layout). Mixing both freely leads to brittle code that’s hard to test.
Step 6: Building the watchOS Companion
Apple Watch has the smallest screen in the lineup, but it’s also the most personal. The goal of the Pixar Film Vault watch companion is not feature parity — it’s focus: show the user their favorites at a glance and let the Digital Crown scroll through the full catalog.
Configuring the Watch Target
In Xcode, select the PixarFilmVault Watch App target. Under General > Frameworks, Libraries, and Embedded Content,
add FilmKit. This is the same package step you performed for the iOS and macOS targets.
Update the Watch app’s entry point at PixarFilmVault Watch App/PixarFilmVaultWatchApp.swift:
import SwiftUI
import SwiftData
import FilmKit
@main
struct PixarFilmVaultWatchApp: App {
var body: some Scene {
WindowGroup {
WatchContentView()
}
.modelContainer(for: PixarFilm.self)
}
}
Building WatchContentView
Create PixarFilmVault Watch App/WatchContentView.swift:
import SwiftUI
import SwiftData
import FilmKit
struct WatchContentView: View {
@Query(sort: \PixarFilm.year) private var films: [PixarFilm]
@State private var selectedFilm: PixarFilm?
@State private var crownValue: Double = 0
var body: some View {
NavigationStack {
List(films) { film in
NavigationLink(value: film) {
WatchFilmRow(film: film)
}
}
.navigationTitle("Film Vault")
.navigationDestination(for: PixarFilm.self) { film in
WatchFilmDetail(film: film)
}
}
.focusable()
.digitalCrownRotation(
$crownValue,
from: 0,
through: Double(films.count - 1),
by: 1,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
}
}
The
digitalCrownRotation
modifier maps Digital Crown rotation to a Double value. Combined with .focusable(), this lets the user scroll the
film list with the crown — the native navigation gesture on Apple Watch.
Apple Docs:
digitalCrownRotation— SwiftUI (watchOS)
Creating WatchFilmRow
The watch row needs to be compact — you have at most 40mm of screen width to work with. Create
PixarFilmVault Watch App/WatchFilmRow.swift:
import SwiftUI
import FilmKit
struct WatchFilmRow: View {
let film: PixarFilm
var body: some View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(film.title)
.font(.headline)
.lineLimit(1)
Text(String(film.year))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if film.isFavorite {
Image(systemName: "heart.fill")
.font(.caption)
.foregroundStyle(.red)
}
}
}
}
Creating WatchFilmDetail
Create PixarFilmVault Watch App/WatchFilmDetail.swift:
import SwiftUI
import FilmKit
struct WatchFilmDetail: View {
@Bindable var film: PixarFilm
@Environment(\.modelContext) private var context
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Text(film.title)
.font(.headline)
Text("\(film.year)")
.font(.caption)
.foregroundStyle(.secondary)
Text(film.synopsis)
.font(.caption2)
.lineSpacing(2)
Button {
film.isFavorite.toggle()
try? context.save()
} label: {
Label(
film.isFavorite ? "Unfavorite" : "Favorite",
systemImage: film.isFavorite ? "heart.fill" : "heart"
)
}
.tint(film.isFavorite ? .red : .blue)
}
.padding()
}
.navigationTitle(film.title)
.navigationBarTitleDisplayMode(.inline)
}
}
Checkpoint: Select the Apple Watch Series 10 (46mm) Simulator from the destination picker and build the
PixarFilmVault Watch Appscheme. You should see a compact film list in the Watch Simulator. Scroll the list and tap a film to see its detail. The favorite button on the detail page should toggle the heart icon.
Step 7: Conditional Compilation and Shared Components
Now that all four targets are building, it’s time to tidy up the cross-cutting concerns: shared imports, platform capability flags, and a strategy for graceful degradation when an API doesn’t exist everywhere.
Platform Capability Flags
Rather than sprinkling #if os(iOS) checks throughout your codebase, define semantic flags in a dedicated file. Create
PixarFilmVault/Utilities/PlatformFlags.swift:
#if os(iOS)
import UIKit
#endif
import Foundation
enum Platform {
#if os(iOS)
static let isIOS = true
static let isIPad = UIDevice.current.userInterfaceIdiom == .pad
#else
static let isIOS = false
static let isIPad = false
#endif
#if os(macOS)
static let isMac = true
#else
static let isMac = false
#endif
#if os(watchOS)
static let isWatch = true
#else
static let isWatch = false
#endif
static let supportsHaptics: Bool = {
#if os(iOS)
return true
#elseif os(watchOS)
return true
#else
return false
#endif
}()
}
These runtime flags are useful when you need to make a conditional decision inside a function body (where #if is valid
but can be noisy). For structural differences in views, #if in the view body is still the clearest approach.
Handling canImport
Some SDKs, like WatchKit, are only importable on their native platform. Use canImport at the file level to guard
against accidental cross-compilation:
#if canImport(WatchKit)
import WatchKit
// WatchKit-specific helpers here
func triggerHaptic(_ type: WKHapticType) {
WKInterfaceDevice.current().play(type)
}
#endif
#if canImport(AppKit)
import AppKit
// AppKit-specific helpers here
func openFinderAtURL(_ url: URL) {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
#endif
canImport is checked at compile time — if WatchKit is not in the SDK being used to compile, the block is excluded
entirely. This is safer than #if os(watchOS) in some edge cases (like Mac Catalyst, which runs iOS frameworks on
macOS).
A Universal Header View
Some view components look nearly identical across platforms but need minor tweaks. A universalPadding helper
centralizes those adjustments:
import SwiftUI
extension View {
func universalPadding() -> some View {
#if os(watchOS)
self.padding(6)
#elseif os(macOS)
self.padding(16)
#else
self.padding(12)
#endif
}
}
Apply .universalPadding() to content containers instead of .padding() and the spacing will automatically adapt to
each platform’s conventions.
Note: Swift 6 makes conditional compilation safer than ever by enforcing that all branches of a
#ifblock are syntactically valid. You can no longer accidentally write code that silently fails to compile on a platform you haven’t tested.
Step 8: CloudKit Sync Across All Platforms
The final step brings everything together: every change a user makes on any platform should appear everywhere else within seconds. SwiftData’s CloudKit integration makes this a configuration change, not a coding change.
Updating ModelConfiguration
Replace the ModelConfiguration in PixarFilmVaultApp.swift with a CloudKit-enabled version:
import SwiftUI
import SwiftData
import FilmKit
@main
struct PixarFilmVaultApp: App {
let container: ModelContainer
init() {
let schema = Schema([PixarFilm.self])
#if os(watchOS)
// watchOS uses a lightweight in-memory store that syncs via CloudKit
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.yourname.PixarFilmVault")
)
#else
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .automatic
)
#endif
do {
container = try ModelContainer(for: schema, configurations: [config])
let context = ModelContext(container)
let existing = try context.fetch(FetchDescriptor<PixarFilm>())
if existing.isEmpty {
PixarFilm.sampleFilms.forEach { context.insert($0) }
try context.save()
}
} catch {
fatalError("Failed to configure ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
The .automatic option tells SwiftData to use the default CloudKit container associated with your app’s bundle ID. The
.private option on watchOS explicitly names the container to ensure both targets point to the same iCloud store.
Apple Docs:
ModelConfiguration— SwiftData
Do the same in the Watch app’s PixarFilmVaultWatchApp.swift:
import SwiftUI
import SwiftData
import FilmKit
@main
struct PixarFilmVaultWatchApp: App {
var body: some Scene {
WindowGroup {
WatchContentView()
}
.modelContainer(
for: PixarFilm.self,
inMemory: false,
isAutosaveEnabled: true,
isUndoEnabled: false
)
}
}
CloudKit Schema Requirements
SwiftData models that use CloudKit have additional requirements:
- All properties must be optional or have default values. CloudKit cannot guarantee that every field exists when
syncing from an older app version. Add default values to
PixarFilmfor any non-optional properties that might be missing from older schema versions. - No unique constraints across relationships that span the CloudKit schema boundary.
@Modelclasses must bepublic finalwhen declared in a package — which yourPixarFilmalready is.
Warning: If you change your
@Modelschema after shipping (adding or removing stored properties), you must handle migration. UseMigrationStageto define the upgrade path. Failing to do so will cause the CloudKit container to reject the new schema.
Testing CloudKit Sync
Testing sync requires two physical devices or, more practically, two simulators signed into the same iCloud sandbox account. Here’s the recommended flow:
- In Xcode, sign in to your sandbox iCloud account under Settings > Developer > iCloud Account.
- Run the iPhone build on Simulator A.
- Run the iPad build on Simulator B (or a second iPhone Simulator).
- On Simulator A, open a film and tap the heart to favorite it.
- On Simulator B, wait 5–15 seconds and pull to refresh. The film should now show a filled heart.
Checkpoint: With CloudKit configured and two Simulator instances running under the same iCloud account, favorite a film on the iPhone Simulator. Within a few seconds, that film should appear as favorited in the iPad Simulator. The same
isFavoritemutation propagates to macOS and watchOS through the shared CloudKit container.Tip: Use the CloudKit Dashboard at icloud.developer.apple.com to inspect your container’s records directly. This is invaluable when debugging sync issues — you can see exactly what records exist, when they were last modified, and what their field values are.
Where to Go From Here?
Congratulations! You’ve built Pixar Film Vault — a single-codebase app that runs natively on iPhone, iPad, Mac, and
Apple Watch, all sharing the same data model, the same CloudKit sync layer, and the same business logic through the
FilmKit local package.
Here’s what you learned:
- Local Swift packages are the right architecture for multiplatform shared code — they enforce clear module boundaries, enable faster incremental builds per platform, and make your shared types genuinely reusable.
NavigationSplitView+horizontalSizeClassgives you an adaptive layout that behaves correctly on compact iPhones and regular-size-class iPads without any manual platform detection in your list or detail views.#if os()andcanImport()let you write platform-specific code in a structured way — use#if os()for structural UI differences andcanImport()when guarding framework imports.ViewModifieris the cleanest way to inject platform-specific behaviors (hover effects, haptics, focus states) into shared view components without duplicating view code.- SwiftData + CloudKit sync is a configuration change, not an architecture change — the same
@Modeltype, the same@Queryviews, and the same@Environment(\.modelContext)pattern work across all four platforms with zero additional code.
Ideas for extending this project:
- tvOS support. Add a fifth destination. The
tvOStarget usesNavigationSplitViewjust like iPad but withfocusable()views and a completely different visual density. YourFilmKitpackage requires zero changes. - macOS menu bar extra. Use
MenuBarExtra(introduced in macOS 13) to add a lightweight favorites-only popover that lives in the menu bar, separate from the main window. - Spotlight search integration. Use
CoreSpotlightto index yourPixarFilmobjects, making them searchable from Spotlight on both iOS and macOS. The index update logic lives inFilmKit— one implementation, two platforms. - Widget extension. Build a WidgetKit widget showing the user’s favorite film of the day. Widget extensions share
FilmKitand the same SwiftData container, so the data is already there.