Files
playbook/antigravity-awesome-skills/skills/swiftui-expert-skill/references/previews.md
T
2026-07-01 16:02:41 +00:00

8.1 KiB

SwiftUI Previews Reference

Table of Contents


Preview Macro

The #Preview macro (Swift 5.9+, Xcode 15+) is the modern way to declare previews. The legacy PreviewProvider protocol still works; prefer #Preview for new code because it's less verbose and supports inline traits.

Basic Usage

// Modern: #Preview macro
#Preview {
    ContentView()
}

// Named preview
#Preview("Dark Mode") {
    ContentView()
        .preferredColorScheme(.dark)
}

// Legacy: PreviewProvider — still valid, but verbose for new code
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Multiple Previews

Declare one #Preview per meaningful state so each renders independently in the canvas:

#Preview("Default") {
    SettingsRow(title: "Notifications", isOn: true)
}

#Preview("Off State") {
    SettingsRow(title: "Notifications", isOn: false)
}

#Preview("Long Title") {
    SettingsRow(title: "Enable Push Notifications for All Events", isOn: true)
}

Preview Traits

Traits configure the preview environment without modifying the view itself:

// Fixed size
#Preview(traits: .fixedLayout(width: 300, height: 100)) {
    CompactBanner(message: "Welcome")
}

// Size that fits content
#Preview(traits: .sizeThatFitsLayout) {
    BadgeView(count: 5)
}

// Landscape orientation
#Preview(traits: .landscapeLeft) {
    DashboardView()
}

Previewing Inside NavigationStack

Wrap previewed destinations in their navigation container so toolbar items, titles, and back buttons render correctly:

#Preview {
    NavigationStack {
        DetailView(item: .sample)
    }
}

Preview with Mock Data

Previews must compile and render without external dependencies. Live services, network calls, and disk I/O make previews slow, flaky, or broken; use self-contained sample data instead.

Static Sample Data

Expose sample values as static properties on the model itself so any preview can reuse them without reconstructing values inline:

struct Item: Identifiable {
    let id: UUID
    var name: String
    var price: Double
}

extension Item {
    static let sample = Item(id: UUID(), name: "Widget", price: 9.99)

    static let samples: [Item] = [
        Item(id: UUID(), name: "Widget", price: 9.99),
        Item(id: UUID(), name: "Gadget", price: 19.99),
        Item(id: UUID(), name: "Doohickey", price: 4.99),
    ]
}

#Preview {
    ItemListView(items: Item.samples)
}

Mock Observable Models

For views driven by an @Observable model (see state-management.md for fundamentals), expose pre-configured instances on the model itself:

@Observable
@MainActor
final class CartModel {
    var items: [Item] = []
    var isLoading = false

    static var preview: CartModel {
        let model = CartModel()
        model.items = Item.samples
        return model
    }

    static var emptyPreview: CartModel {
        CartModel()
    }

    static var loadingPreview: CartModel {
        let model = CartModel()
        model.isLoading = true
        return model
    }
}

#Preview("With Items") {
    CartView()
        .environment(CartModel.preview)
}

#Preview("Empty") {
    CartView()
        .environment(CartModel.emptyPreview)
}

#Preview("Loading") {
    CartView()
        .environment(CartModel.loadingPreview)
}

Preview with Environment Dependencies

Inject any environment values the view depends on so the preview reflects a realistic runtime context:

#Preview {
    OrderDetailView(order: .sample)
        .environment(CartModel.preview)
        .environment(\.locale, Locale(identifier: "ja_JP"))
        .environment(\.dynamicTypeSize, .xxxLarge)
}

Mocking Async Data Sources

When a view depends on a network or data service, give the dependency a protocol abstraction so previews can inject a synchronous mock that returns sample data immediately. This is one approach — adapt it to whatever pattern the surrounding codebase already uses.

protocol DataFetching {
    func fetchItems() async throws -> [Item]
}

struct LiveDataFetcher: DataFetching {
    let url: URL

    func fetchItems() async throws -> [Item] {
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Item].self, from: data)
    }
}

struct MockDataFetcher: DataFetching {
    var result: Result<[Item], Error> = .success(Item.samples)

    func fetchItems() async throws -> [Item] {
        try result.get()
    }
}

#Preview {
    ItemListView(fetcher: MockDataFetcher())
}

#Preview("Error State") {
    ItemListView(fetcher: MockDataFetcher(result: .failure(URLError(.notConnectedToInternet))))
}

@Previewable Property Wrappers

@Previewable (iOS 18+, Xcode 16+) lets you use @State, @FocusState, and other property wrappers directly inside a #Preview block, removing the need for a wrapper view to host interactive state.

Interactive State

// @Previewable: interactive toggle inline in the preview
#Preview {
    @Previewable @State var isOn = false
    Toggle("Notifications", isOn: $isOn)
}

// Without @Previewable: requires a wrapper view
struct TogglePreviewWrapper: View {
    @State private var isOn = false
    var body: some View {
        Toggle("Notifications", isOn: $isOn)
    }
}

#Preview {
    TogglePreviewWrapper()
}

Multiple Interactive Controls

#Preview {
    @Previewable @State var name = "Alice"
    @Previewable @State var age = 25.0

    VStack {
        TextField("Name", text: $name)
        Slider(value: $age, in: 0...100, step: 1) {
            Text("Age: \(Int(age))")
        }
        Text("Hello, \(name)! Age: \(Int(age))")
    }
    .padding()
}

@Previewable with @FocusState

When seeding initial focus inside a preview, prefer .defaultFocus over writing to @FocusState from .onAppear. .onAppear can race the initial render and the focus assignment may be lost. See focus-patterns.md for the underlying rationale.

#Preview {
    @Previewable @FocusState var isFocused: Bool

    TextField("Search", text: .constant(""))
        .focused($isFocused)
        .defaultFocus($isFocused, true)
}

Fallback for Pre-iOS 18 Targets

If the project's minimum deployment target is below iOS 18, @Previewable is unavailable. Fall back to a wrapper view:

private struct SliderPreview: View {
    @State private var value = 0.5
    var body: some View {
        CustomSlider(value: $value)
    }
}

#Preview {
    SliderPreview()
}

Common Diagnostics

Symptom Cause Fix
#Preview body type mismatch The closure returns a non-View type Make sure the final expression is a View
@Previewable only available in iOS 18+ Using @Previewable with a lower deployment target Use a wrapper view, or gate with #available
Preview crashes with "missing environment" An @Environment(SomeType.self) value is not injected Add .environment(SomeType.preview) to the preview
Preview hangs or renders blank View depends on async data that never resolves Inject a mock that returns immediately with sample data
@MainActor-isolated model accessed from non-isolated context A preview helper touches main-actor-only API off the main actor Mark the helper or the preview body @MainActor

Summary Checklist

  • Prefer #Preview for new previews; PreviewProvider is still valid for older code
  • Provide a named preview for each meaningful state (default, empty, error, loading)
  • Use @Previewable for interactive previews when targeting iOS 18+; wrapper views otherwise
  • Expose static .sample / .preview data on models so previews don't reconstruct values inline
  • Inject mock services through a protocol when a view depends on async data
  • Never depend on live network or disk I/O in a preview
  • Prefer .defaultFocus over .onAppear writes when seeding @FocusState in previews