314 lines
8.1 KiB
Markdown
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
|