SwiftUI on the Mac: Build the fundamentals
Swift & UI 进阶 28m

Mac 上的 SwiftUI:构建基础

SwiftUI on the Mac: Build the fundamentals

2021年6月10日

在 Apple 官方观看视频

一句话判断

SwiftUI 终于在 macOS 上拿到了 TableCommandsToolbar 这三个做 Mac app 的必备组件——如果你之前因为「SwiftUI 做不出正经 Mac app」而犹豫,现在可以重新评估了。

这场 Session 讲了什么

SwiftUI 在 macOS 12 的改进让它终于可以用来构建生产级的 Mac app。Session 用一个完整的「菜谱管理」app 作为示例,展示了如何用纯 SwiftUI 构建 macOS app 的核心组件。

Table 是最大的新增组件。它对应 AppKit 的 NSTableView,支持多列、排序、选择(单选/多选)、自定义单元格。配合 TableColumnTableRow,你可以用声明式代码创建复杂的数据表格。Table 在 iPadOS 上会自动降级为 List。

Commands 修饰符让你可以在 SwiftUI 中配置 Mac 的菜单栏。CommandGroupCommandMenuCommandButton 可以精确控制菜单的层级和快捷键。系统菜单(File、Edit、View)中的项也可以被替换或追加。

Toolbar 在 macOS 上的行为得到了完善。ToolbarItemplacement 参数现在可以精确控制项目在工具栏中的位置(principal、navigation、confirmationAction 等)。

值得深挖的点

Table 的数据绑定模式

Table 支持两种数据绑定:静态列(编译时确定列数)和动态列(运行时确定列数)。静态列使用 TableColumn 泛型,性能更好。动态列使用 columns 参数,更灵活。Session 建议:如果你的表格结构固定,用静态列;如果用户可以自定义显示哪些列,用动态列。

多窗口和状态管理

macOS app 通常需要支持多窗口。SwiftUI 的 WindowGroup 会自动处理多窗口的创建和管理。每个窗口有独立的 @State@SceneStorage。如果需要跨窗口共享状态,用 @AppStorage 或者 @EnvironmentObject

代码片段

// 使用 Table 构建数据表格
import SwiftUI

struct RecipeListView: View {
    let recipes: [Recipe]
    @State private var selection: Recipe.ID?
    @State private var sortOrder = [KeyPathComparator(\Recipe.name)]
    @State private var sortedRecipes: [Recipe] = []
    
    var body: some View {
        Table(sortedRecipes, selection: $selection, sortOrder: $sortOrder) {
            TableColumn("名称", value: \.name) { recipe in
                HStack {
                    Image(systemName: "fork.knife")
                    Text(recipe.name)
                }
            }
            TableColumn("分类", value: \.category) { recipe in
                Text(recipe.category.rawValue)
            }
            TableColumn("时间", value: \.cookingTime) { recipe in
                Text("\(recipe.cookingTime) 分钟")
            }
            TableColumn("难度", value: \.difficulty) { recipe in
                DifficultyIndicator(level: recipe.difficulty)
            }
        }
        .onChange(of: sortOrder) { order in
            sortedRecipes = recipes.sorted(using: order)
        }
        .onAppear {
            sortedRecipes = recipes
        }
    }
}

struct Recipe: Identifiable {
    let id: UUID
    let name: String
    let category: Category
    let cookingTime: Int
    let difficulty: Int
}
// 配置 Mac 菜单栏
@main
struct RecipeApp: App {
    var body: some Scene {
        WindowGroup {
            RecipeListView(recipes: sampleRecipes)
        }
        .commands {
            // 在文件菜单中添加项
            CommandGroup(after: .newItem) {
                Button("导入菜谱") {
                    importRecipe()
                }
                .keyboardShortcut("i", modifiers: [.command, .shift])
                
                Button("导出菜谱") {
                    exportRecipes()
                }
            }
            
            // 自定义菜单
            CommandMenu("筛选") {
                Button("显示所有") { filterRecipes(.all) }
                    .keyboardShortcut("0", modifiers: .command)
                Button("仅显示甜点") { filterRecipes(.dessert) }
                    .keyboardShortcut("1", modifiers: .command)
                Button("仅显示主食") { filterRecipes(.main) }
                    .keyboardShortcut("2", modifiers: .command)
            }
        }
    }
}
// 配置 macOS 工具栏
struct RecipeDetailView: View {
    let recipe: Recipe
    
    var body: some View {
        VStack {
            Text(recipe.name)
                .font(.title)
        }
        .toolbar {
            ToolbarItemGroup(placement: .primaryAction) {
                Button(action: editRecipe) {
                    Label("编辑", systemImage: "pencil")
                }
                
                Button(action: shareRecipe) {
                    Label("分享", systemImage: "square.and.arrow.up")
                }
                
                Button(action: printRecipe) {
                    Label("打印", systemImage: "printer")
                }
            }
            
            ToolbarItem(placement: .navigation) {
                Button(action: goBack) {
                    Label("返回", systemImage: "chevron.left")
                }
            }
        }
    }
}

最佳实践

在 macOS 上开发 SwiftUI app 时,用 NavigationSplitView 替代旧的 NavigationViewNavigationSplitView 会自动在 macOS 上创建侧边栏+内容的分栏布局,在 iPadOS 上也能正确适配。

菜单栏中的快捷键不要和系统快捷键冲突。避免使用 Cmd+Q、Cmd+H、Cmd+Space 等系统保留的快捷键。如果你的 app 有编辑功能,确保 Cmd+Z(撤销)和 Cmd+Shift+Z(重做)都有实现。

还有什么值得关注

  • Table 在 iOS 上不支持,但会自动降级为 List。用 #if os(macOS) 条件编译来区分平台。
  • Settings scene 可以创建标准的 Mac 偏好设置窗口。
  • DocumentGroup 可以创建标准的 Mac 文档编辑器,自动处理文件的新建、打开、保存。
WWDC 2021