SwiftUI @Previewable 宏实践应用总结

5 阅读6分钟

SwiftUI @Previewable 宏实践应用总结

SwiftUI 自推出以来,一直在简化 UI 开发流程,其中预览(Previews)功能是开发者日常工作中不可或缺的工具。随着 Xcode 16 的发布,Apple 引入了 @Previewable 宏,这标志着 SwiftUI 预览功能进入了一个新的时代。这个宏专门用于解决在预览中处理动态状态(如 @State@Binding)时的样板代码问题,让开发者能够更直接、更高效地创建交互式预览。

1. SwiftUI 预览功能的演进

SwiftUI 的预览功能允许开发者在编写 UI 代码的同时,实时查看界面效果,无需反复编译和运行应用。在 Xcode 15 及更早版本中,我们主要依赖 #Preview 宏或更早的 PreviewProvider 协议来定义预览。

1.1 从 PreviewProvider 到 #Preview 宏

在引入 #Preview 宏之前,开发者需要为每个需要预览的视图创建一个符合 PreviewProvider 协议的结构体。该协议要求实现一个静态的 previews 计算属性,返回要预览的视图。


// 旧的 PreviewProvider 方式

struct ContentView_Previews: PreviewProvider {

static var previews: some View {

ContentView()

}

}

Xcode 15 和 Swift 5.9 引入了 #Preview 宏,极大地简化了预览的定义:


// 新的 #Preview 宏方式

#Preview {

ContentView()

}

#Preview 宏不仅语法更简洁,还支持更多功能,如为预览命名、预览多个视图、甚至预览 UIKit 和 AppKit 视图。

1.2 预览动态状态的挑战

尽管 #Preview 宏带来了便利,但在处理需要内部状态的视图时,开发者仍然面临挑战。例如,如果你想预览一个 Toggle 开关的不同状态(开启和关闭),在 #Preview 宏中无法直接定义可变的本地状态变量。

常见的解决方案是为每个状态创建单独的预览:


#Preview("On") {

Toggle("Enable slow animations", isOn: .constant(true))

}

  


#Preview("Off") {

Toggle("Enable slow animations", isOn: .constant(false))

}

或者,更繁琐地,创建一个专用的容器视图来包装状态:


struct TogglePreviewContainer: View {

@State private var isOn: Bool = false

var body: some View {

Toggle("Enable slow animations", isOn: $isOn)

}

}

  


#Preview("Dynamic") {

TogglePreviewContainer()

}

这两种方法都有缺点:前者无法实现真正的交互,后者则增加了样板代码和额外的类型定义。

2. @Previewable 宏的引入与核心概念

Xcode 16 推出的 @Previewable 宏旨在解决上述痛点,它允许开发者在 #Preview 宏中直接使用动态属性,如 @State,从而创建真正交互式的预览,而无需编写额外的包装代码。

2.1 @Previewable 宏的基本用法

使用 @Previewable 宏非常简单。你只需在 #Preview 宏内部的变量声明前添加 @Previewable 属性:


#Preview("New in Xcode 16") {

@Previewable @State var isOn: Bool = false // 使用 @Previewable 标记动态状态

  


Toggle("Enable slow animations", isOn: $isOn)

}

在这段代码中:

  • @Previewable 宏修饰了 @State var isOn: Bool = false 这行声明。

  • 这使得我们能够在预览中直接使用 $isOn 来绑定到 Toggle。

  • 结果是在 Xcode 的预览画布中,你可以直接点击 Toggle 来切换其状态,并立即看到效果,预览变得真正可交互。

2.2 @Previewable 宏的工作原理

理解 @Previewable 宏的背后机制有助于更好地使用它。Swift 宏(Macro)是一种在编译时扩展代码的机制。编译器遇到宏时,会将其展开为预先定义好的 Swift 代码。

当你使用 @Previewable 宏时,Swift 编译器在编译期间会将其转换成一个包装器视图结构。例如,上面的示例代码大致会被展开成类似以下形式:


// 注意:这是宏展开的示意代码,实际生成的代码可能更复杂并由编译器管理

struct __P_Previewable_Transform_Wrapper: View {

@State private var isOn: Bool = false // 宏将 tagged declarations 变为视图的属性

  


var body: some View {

Toggle("Enable slow animations", isOn: $isOn) // 所有其余语句构成视图的 body

}

}

  


#Preview("New in Xcode 16") {

__P_Previewable_Transform_Wrapper()

}

可以看到,@Previewable 宏自动为我们完成了之前需要手动编写的包装器视图(如之前的 TogglePreviewContainer)的创建工作。它将被标记的声明(如 @State var isOn)变成了生成的那个包装器视图的属性,而其他语句则构成了该视图的 body。

这种自动生成代码的方式极大地减少了开发者需要编写的样板代码,使得预览代码更加紧凑和易于维护。

3. @Previewable 宏的高级用法与技巧

@Previewable 宏的强大之处在于它能处理各种复杂的预览场景。

3.1 预览多个交互式视图

你可以在一个预览文件中定义多个 #Preview 宏,每个都可以使用 @Previewable 来管理自己的独立状态。


// 预览一个简单的计数器

#Preview("Counter") {

@Previewable @State var count: Int = 0

  


VStack {

Text("Count: \(count)")

Button("Increment") { count += 1 }

Button("Decrement") { count -= 1 }

}

}

  


// 预览一个文本输入框

#Preview("Text Input") {

@Previewable @State var text: String = "Hello World"

  


TextField("Enter text", text: $text)

.padding()

.textFieldStyle(.roundedBorder)

}

3.2 结合其他属性包装器

@Previewable 不仅可以与 @State 结合使用,还可以与其他属性包装器(如 @StateObject@ObservedObject@EnvironmentObject)协同工作,用于预览更复杂的、涉及外部数据源的视图。


class MyViewModel: ObservableObject {

@Published var username: String = "Jane Doe"

}

  


#Preview("With ViewModel") {

@Previewable @StateObject var viewModel = MyViewModel() // 使用 @StateObject

  


VStack {

TextField("Username", text: $viewModel.username)

Text("You entered: \(viewModel.username)")

}

}

3.3 模拟导航和交互流程

对于涉及导航(Navigation)的视图,你可以使用 @Previewable 来模拟导航状态,例如模拟一个是否处于导航链接激活状态的状态变量。


#Preview("Navigation Simulation") {

@Previewable @State var isNavigationLinkActive: Bool = false

  


NavigationStack {

Button("Show Detail") {

isNavigationLinkActive = true

}

.navigationDestination(isPresented: $isNavigationLinkActive) {

Text("Detail View")

.toolbar {

Button("Done") {

isNavigationLinkActive = false

}

}

}

}

}

4. @Previewable 与 #Preview 宏的协同效应

@Previewable 宏的设计初衷是与 #Preview 宏协同工作,它们共同构成了 SwiftUI 预览的新范式。

4.1 #Preview 宏的增强功能

在 Xcode 15 中,#Preview 宏已经引入了许多强大功能,这些功能在与 @Previewable 结合时更能发挥效用:

  • 多预览支持:轻松创建多个预览,而无需使用 Group

#Preview("Light") {

ContentView()

.preferredColorScheme(.light)

}

#Preview("Dark") {

ContentView()

.preferredColorScheme(.dark)

}

  • 预览 UIKit/AppKit 视图#Preview 宏允许预览传统的 UIKit 视图控制器和 AppKit 视图,这对于逐步迁移到 SwiftUI 的项目非常有用。

#Preview {

let vc = MyUIKitViewController()

vc.title = "Previewable UIKit!"

return vc

}

  • 设置预览特征(Traits):可以通过 traits 参数指定预览的设备方向、大小等。

#Preview("Landscape", traits: .landscapeRight) {

ContentView()

}

4.2 结合使用的最佳实践

@Previewable 用于管理内部状态,同时利用 #Preview 的参数来配置预览环境,这是最有效的用法。


// 定义一个需要状态的产品详情视图

struct ProductDetailView: View {

@Binding var isFavorite: Bool

let productName: String

  


var body: some View {

VStack {

Text(productName)

Button(isFavorite ? "Remove Favorite" : "Add Favorite") {

isFavorite.toggle()

}

}

}

}

  


// 在预览中,使用 @Previewable 提供状态绑定,并使用 #Preview 配置预览

#Preview("Product A", traits: .sizeThatFitsLayout) { // 使用 traits 调整预览布局

@Previewable @State var isFav: Bool = false // 使用 @Previewable 管理状态

  


ProductDetailView(isFavorite: $isFav, productName: "Awesome Product A")

}

  


#Preview("Product B - Favorite", traits: .fixedLayout(width: 300, height: 200)) {

@Previewable @State var isFav: Bool = true // 另一个预览,拥有独立的状态

  


ProductDetailView(isFavorite: $isFav, productName: "Great Product B")

}

5. 实际开发案例与场景分析

让我们通过几个更贴近实际开发的例子,来深入理解 @Previewable 宏如何提升开发效率。

5.1 案例一:用户登录表单

预览一个典型的登录表单,包含用户名、密码输入框和一个可启用的登录按钮。


struct LoginForm: View {

@Binding var username: String

@Binding var password: String

  


var body: some View {

Form {

TextField("Username", text: $username)

SecureField("Password", text: $password)

Button("Login") {

// 处理登录逻辑

}

.disabled(username.isEmpty || password.isEmpty) // 表单未填写时禁用按钮

}

}

}

  


#Preview("Login Form") {

@Previewable @State var username: String = ""

@Previewable @State var password: String = ""

  


LoginForm(username: $username, password: $password)

}

在这个预览中,你可以直接输入文本,并观察到登录按钮的启用/禁用状态会实时响应输入内容的变化。

5.2 案例二:动态列表操作

预览一个可以添加、删除和移动项目的列表。


struct EditableListView: View {

@Binding var items: [String]

  


var body: some View {

List {

ForEach(items.indices, id: \.self) { index in

TextField("Item", text: $items[index])

}

.onDelete { indices in

items.remove(atOffsets: indices)

}

.onMove { indices, newOffset in

items.move(fromOffsets: indices, toOffset: newOffset)

}

}

.toolbar {

EditButton()

Button("Add") {

items.append("New Item")

}

}

}

}

  


#Preview("Editable List") {

@Previewable @State var listItems = ["Apple", "Banana", "Orange"]

  


NavigationStack {

EditableListView(items: $listItems)

}

}

这个预览允许你完全交互:添加新项目、编辑现有文本、删除项目以及重新排序。

5.3 案例三:网络加载状态模拟

预览一个视图在不同加载状态(加载中、成功、失败)下的表现。


enum LoadState {

case loading, success, failure

}

  


struct StatusView: View {

let state: LoadState

var retryAction: (() -> Void)?

  


var body: some View {

Group {

switch state {

case .loading:

ProgressView()

case .success:

Text("Success!")

case .failure:

VStack {

Text("Failed to load.")

Button("Retry") {

retryAction?()

}

}

}

}

}

}

  


#Preview("Loading States") {

@Previewable @State var currentState: LoadState = .loading

  


VStack {

Picker("State", selection: $currentState) {

Text("Loading").tag(LoadState.loading)

Text("Success").tag(LoadState.success)

Text("Failure").tag(LoadState.failure)

}

.pickerStyle(.segmented)

  


StatusView(state: currentState) {

print("Retry tapped in preview!") // 你甚至可以在预览中模拟重试操作

}

.frame(height: 200)

}

.padding()

}

这个例子展示了如何使用 @Previewable 状态和 Picker 来快速切换和预览同一视图在不同数据状态下的外观,无需运行整个应用。

6. 兼容性与迁移建议

6.1 平台版本要求

需要注意的是,#Preview 宏本身要求项目部署目标至少为 iOS 17、macOS Sonoma 14.0、tvOS 17.0 或 watchOS 10.0。由于 @Previewable 宏是随 Xcode 16 新引入的,它很可能有相同或更高的版本依赖。在编写代码时,Xcode 会明确指出任何平台版本不兼容的问题。

6.2 向后兼容的考量

如果你的项目需要支持旧版操作系统(如 iOS 16 或更早版本),你可能会遇到一个问题:虽然主应用程序代码可以针对旧版系统编译,但包含 #Preview@Previewable 宏的预览代码会导致编译错误,因为它们需要更新的 SDK。

目前的一个常见解决方法是:

  1. 条件编译:使用 #if 条件编译指令将预览代码包裹起来,只在满足条件时(例如,使用特定的 SDK 或调试配置时)才编译这些宏。

#if canImport(SwiftUI) && hasAttribute(previewable) // 或者使用具体的版本检查,如 #if compiler(>=5.9)

#Preview {

@Previewable @State var isOn = false

Toggle("Preview", isOn: $isOn)

}

#endif

但这并非完美方案,有时仍会遇到挑战。

  1. 分离预览代码:有些人选择将预览代码放在单独的文件中,或者通过项目配置来管理预览的编译。

Apple 已意识到向后部署(backwards deployment)相关的问题,并可能在未来的 Xcode 更新中提供更好的解决方案。

6.3 从旧版 PreviewProvider 迁移

如果你现有的项目在使用 PreviewProvider,迁移到 #Preview@Previewable 宏是一个好主意,因为它能简化代码并提供更强大的功能。迁移过程通常是直截了当的:

  1. 找到符合 PreviewProvider 协议的结构体(例如 ContentView_Previews)。

  2. 将其替换为一个或多个 #Preview 宏。

  3. 如果预览需要内部状态,使用 @Previewable 来声明状态变量,而不是创建额外的容器视图。

7. 总结

@Previewable 宏是 SwiftUI 预览功能发展过程中的一个重大进步,它直接解决了开发者在创建交互式预览时面临的核心痛点。

  • 减少样板代码:无需再为预览状态而手动创建额外的容器视图结构,代码更简洁、更集中于视图本身。

  • 真正的交互性:预览不再是静态的图片,而是完全可交互的界面,你可以直接测试开关、按钮、文本输入、导航等行为。

  • 提升开发效率:实时交互反馈极大地加速了 UI 开发和调试迭代的过程,开发者可以更快地验证想法和修复问题。

  • 与 Swift 宏生态无缝集成:作为 Swift 宏系统的一部分,@Previewable 受益于编译时的安全性和扩展性。

原文:xuanhu.info/projects/it…