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

Contents

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:

  1. Choose File > New > Project.
  2. Select the Multiplatform tab, then choose App.
  3. Set the Product Name to PixarFilmVault.
  4. Set Organization Identifier to your reverse-domain identifier (e.g., com.yourname).
  5. Choose Swift as the language and SwiftUI as the interface.
  6. 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:

  1. Under General > Supported Destinations, click the + button.
  2. Add macOS as a destination.
  3. 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:

  1. Single source of truth. Every platform target imports the same module — there is no risk of copy-paste drift.
  2. 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.
  3. 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 — SwiftData

Checkpoint: 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: NavigationSplitView automatically 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 in ContentView.

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 ContentView renders 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 — SwiftUI

Tip: Prefer ViewModifier for 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 App scheme. 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 #if block 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:

  1. 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 PixarFilm for any non-optional properties that might be missing from older schema versions.
  2. No unique constraints across relationships that span the CloudKit schema boundary.
  3. @Model classes must be public final when declared in a package — which your PixarFilm already is.

Warning: If you change your @Model schema after shipping (adding or removing stored properties), you must handle migration. Use MigrationStage to 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:

  1. In Xcode, sign in to your sandbox iCloud account under Settings > Developer > iCloud Account.
  2. Run the iPhone build on Simulator A.
  3. Run the iPad build on Simulator B (or a second iPhone Simulator).
  4. On Simulator A, open a film and tap the heart to favorite it.
  5. 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 isFavorite mutation 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 + horizontalSizeClass gives 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() and canImport() let you write platform-specific code in a structured way — use #if os() for structural UI differences and canImport() when guarding framework imports.
  • ViewModifier is 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 @Model type, the same @Query views, 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 tvOS target uses NavigationSplitView just like iPad but with focusable() views and a completely different visual density. Your FilmKit package 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 CoreSpotlight to index your PixarFilm objects, making them searchable from Spotlight on both iOS and macOS. The index update logic lives in FilmKit — one implementation, two platforms.
  • Widget extension. Build a WidgetKit widget showing the user’s favorite film of the day. Widget extensions share FilmKit and the same SwiftData container, so the data is already there.