SwiftUI previews are essential for iterating on views quickly, but most projects use them far below their potential. A preview that shows an empty view with default data is not useful. A preview that shows real states — loading, error, populated, dark mode, different locales — can replace most of your visual testing.
This guide covers preview techniques for production SwiftUI projects: previewing multiple states, working with dependencies, mock data, device configurations, and avoiding common pitfalls.
The most impactful improvement: show every state your view can be in.
#Preview("Populated") {
ContentView(items: sampleItems)
}
#Preview("Empty") {
ContentView(items: [])
}
#Preview("Loading") {
ContentView(isLoading: true, items: [])
}
#Preview("Error") {
ContentView(error: "Failed to load data", items: [])
}Xcode renders each preview in a separate canvas pane. You see all states at once.
With the #Preview macro (iOS 17+ / Xcode 15+), each preview is independent:
#Preview {
ProfileView(user: .previewAlice)
}
#Preview {
ProfileView(user: .previewBob)
.preferredColorScheme(.dark)
}
#Preview {
ProfileView(user: .previewCharlie)
.environment(\.locale, .init(identifier: "vi"))
}Add preview-specific static properties to your models:
extension User {
static let previewAlice = User(
id: UUID(),
name: "Alice Nguyen",
avatar: "alice",
isPro: true
)
static let previewBob = User(
id: UUID(),
name: "Bob Smith",
avatar: nil,
isPro: false
)
}Create a PreviewContent.swift file for mock data used across previews:
enum PreviewData {
static let articles: [Article] = [
Article(id: "1", title: "Getting Started with SwiftUI", date: .now),
Article(id: "2", title: "Advanced Navigation Patterns", date: .now.addingTimeInterval(-86400)),
Article(id: "3", title: "Performance Optimization Tips", date: .now.addingTimeInterval(-172800)),
]
static let comments: [Comment] = [
Comment(id: "1", author: "Alice", text: "Great article!"),
Comment(id: "2", author: "Bob", text: "This helped a lot, thanks."),
]
}If your views use services or environment objects, provide preview-specific stubs.
protocol APIClientProtocol {
func fetchArticles() async throws -> [Article]
}
// Preview stub
struct PreviewAPIClient: APIClientProtocol {
let result: [Article]
func fetchArticles() async throws -> [Article] {
result
}
}
#Preview("With data") {
let client = PreviewAPIClient(result: PreviewData.articles)
let viewModel = ArticleListViewModel(client: client)
ArticleListView(viewModel: viewModel)
}private struct APIClientKey: EnvironmentKey {
static let defaultValue: APIClientProtocol = ProductionAPIClient()
}
extension EnvironmentValues {
var apiClient: APIClientProtocol {
get { self[APIClientKey.self] }
set { self[APIClientKey.self] = newValue }
}
}
#Preview {
ArticleListView()
.environment(\.apiClient, PreviewAPIClient(result: []))
}Preview multiple devices without switching schemes:
#Preview("iPhone", traits: .portrait) {
ContentView()
}
#Preview("iPad", traits: .landscapeRight) {
ContentView()
}
#Preview("iPhone SE", traits: .fixedLayout(width: 375, height: 667)) {
ContentView()
}For precise size control:
#Preview {
ContentView()
.previewDevice("iPhone SE (3rd generation)")
}#Preview {
ContentView()
.dynamicTypeSize(.accessibility5)
}
#Preview("Large Text") {
ContentView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
}#Preview("Dark") {
ContentView()
.preferredColorScheme(.dark)
}#Preview("Vietnamese") {
ContentView()
.environment(\.locale, Locale(identifier: "vi"))
.environment(\.layoutDirection, .leftToRight)
}
#Preview("Arabic") {
ContentView()
.environment(\.locale, Locale(identifier: "ar"))
.environment(\.layoutDirection, .rightToLeft)
}#Preview("Bold Text") {
ContentView()
.environment(\.legibilityWeight, .bold)
}
#Preview("Reduced Motion") {
ContentView()
.environment(\.accessibilityReduceMotion, true)
}For related previews, group them:
#Preview(traits: .sizeThatFitsLayout) {
VStack {
ProfileView(user: .previewAlice)
ProfileView(user: .previewBob)
ProfileView(user: .previewCharlie)
}
}When working with a UIViewRepresentable:
#Preview {
MapView()
.frame(height: 300)
}If your app uses AppDelegate or @main logic that crashes without certain conditions, SwiftUI previews may fail. Guard early using ProcessInfo:
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
// Skip initialization that crashes in previews
}@AppStorage or UserDefaults changes during a preview session.@State mutations in preview bodies..sizeThatFitsLayout trait for views that do not need full-screen rendering.Previews re-create the view tree on each code change. Any @State is discarded and re-initialized. If you need to persist state across preview edits, lift it to a parent or use @State with initial values that match your preview scenario.
Use @Previewable (iOS 17+, Xcode 16+) to declare mutable @State inline in a #Preview without creating a wrapper view:
#Preview {
@Previewable @State var toggled = true
Toggle("Loud Noises", isOn: $toggled)
}#Preview works directly with UIViewController and UIView instances:
#Preview {
let vc = UIHostingController(rootView: ProfileView(user: .previewAlice))
vc.view.frame = CGRect(x: 0, y: 0, width: 400, height: 800)
return vc
}For custom UIViewController subclasses, just pass them in directly:
#Preview {
CustomViewController()
}Before considering a preview “done,” verify: