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

314 lines
8.1 KiB
Markdown

# SwiftUI Previews Reference
## Table of Contents
- [Preview Macro](#preview-macro)
- [Preview with Mock Data](#preview-with-mock-data)
- [@Previewable Property Wrappers](#previewable-property-wrappers)
- [Common Diagnostics](#common-diagnostics)
- [Summary Checklist](#summary-checklist)
---
## 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
```swift
// 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:
```swift
#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:
```swift
// 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:
```swift
#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:
```swift
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:
```swift
@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:
```swift
#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.
```swift
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
```swift
// @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
```swift
#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.
```swift
#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:
```swift
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