WWDC22: SwiftUI 的新功能 part 1

发布
更新
字数 1838
阅读 10 分钟
阅读量 1390

SwiftUI 是 Apple 发布于 WWDC 2019 的 UI 框架,采用声明式语法,具有数据绑定、响应式编程及跨平台(Apple 全家桶)等特征。WWDC22 发布了一些新的 API,大大提升了实用性。

Swift Chart

Swift Charts 是 WWDC22 新发布的跨平台图表框架,同 SwiftUI 一样使用声明式语法,很容易自定义。所有的 Chart 对象都是 some View,所以,它就是一个 SwiftUI 视图组件,支持大部分的 SwiftUI modifier。

var body: some View {
    // 作为 `some View` 返回
    Chart(partyTasksRemaining) {
        LineMark(
            x: .value("Date", $0.date, unit: .day),
            y: .value("Tasks Remaining", $0.remainingCount)
        )
        .foregroundStyle(by: .value("Category", $0.category))
    }
    // 使用 `padding()` 修改器
    .padding()
}

也可以结合 SwiftUI 其他组件一起使用

var body: some View {
    Chart {
        ForEach(partyTasksRemaining) { task in
            LineMark(
                x: .value("Date", task.date, unit: .day),
                y: .value("Tasks Remaining", task.remainingCount)
            )
            .foregroundStyle(by: .value("Category", task.category))
            .symbol(by: .value("Category", task.category))
            .annotation(position: .leading) {
                // 使用文本标注
                Text("\(task.category.emoji)")
            }
        }

        RuleMark(y: .value("Value", 5))
            .foregroundStyle(.red)
            .lineStyle(StrokeStyle(lineWidth: 2.0, dash: [4, 5]))
            .annotation(position: .top, alignment: .trailing) {
                // 使用一组文本
                VStack(alignment: .trailing) {
                    Text("Today's Goal")
                    Text("Status: ✔︎")
                }
                // 并修饰
                .font(.caption)
                .foregroundColor(.gray)
                .padding(.trailing, 2)
            }
    }
}

了解 Swift Charts:Swift Charts 初探再谈 Swift Charts

Navigation and windows

SwiftUI 已经支持常见的导航模式,如 push-and-pop 导航堆栈,分隔窗口和多窗口。WWDC22 对这三种导航模式进行了升级。

NavigationStack

新的容器视图,用来支持 push-and-pop 风格的导航。

向下兼容

NavigationStack {
    List(foodItems) { item in
        // 支持已有的 `NavigationLink`
        NavigationLink {
            // 子视图
            FoodDetailView(item: item)
        } label: {
            FoodRow(food: item)
        }
    }
    // 和 `navigationTitle()`
    .navigationTitle("Party Food")

}

数据驱动

NavigationStack 提供了新的数据驱动的方式,通过编程控制要展示的内容。新的 navigationDestination() 修改器可以把导航目标与数据类型进行绑定,即根据不同的类型展示对应的 detail view

NavigationStack {
    List(foodItems) { item in
        // 1. 给定一个 value 用来设定导航目标
        NavigationLink(value: item) {
            Label(item.title, image: item.iconName)
        }
    }
    .navigationTitle("Party Food")
    // 2. 当点击链接,会根据提供的 value 类型判断导航目标
    .navigationDesination(for: FooItem.self) { item in
        FoodDetailView(item: item)
    }
}

想象一下在该示例中添加一下功能:

  • 在 food detail view 添加一个相关 food 视图,显示一系列 food item
  • 点击相关食物会 push 到新的 detail 页面
  • 在任意 food detail view 可以点击一个返回按钮,返回最初选择的 food item

已访问 food item 会存储在一个数组中,用来表示访问路径的堆栈,所以可以直接通过访问和修改该数组来实现_返回_的操作。

struct FoodsListView: View {
    fileprivate var foodItems = partyFoods
    
    // 1. 已访问的 food item 存储 state
    @State private var selectedFoodItems: [FoodItem] = []

    var body: some View {
        // 2. 加入导航堆栈
        NavigationStack(path: $selectedFoodItems) {
            List(foodItems) { item in
                NavigationLink(value: item) {
                    FoodRow(food: item)
                }
            }
            .navigationTitle("Party Food")
            .navigationDestination(for: FoodItem.self) { item in
                FoodDetailView(item: item, path: $selectedFoodItems)
            }
        }
    }
}

struct FoodDetailView: View {
    let item: FoodItem
    // 3. 绑定 path
    @Binding var path: [FoodItem]

    var body: some View {
        ScrollView {
            VStack {
                HStack {
                    Text(item.emoji)
                        .font(.system(size: 30))
                    Text(item.name)
                        .font(.title3)
                }
                .padding(.bottom, 4)
                Text(item.description)
                    .font(.caption)
                Divider()
                RelatedFoodsView(relatedFoods: relatedFoods.random(3, except: item))
                if path.count > 1 {
                    // 4. 点击返回按钮,移除第一个 food item 以外其他项目
                    Button("Back to First Item") { path.removeSubrange(1...) }
                }
            }
        }
    }
}

struct RelatedFoodsView: View {
    @State var relatedFoods: [FoodItem]

    var body: some View {
        VStack {
            Text("Related Foods")
                .background(.background, in: RoundedRectangle(cornerRadius: 2))
            HStack {
                ForEach(relatedFoods) { food in
                    NavigationLink(value: food) {
                        Text(food.emoji)
                    }
                }
            }
        }
    }
}

NavigationSplitView

分割视图主要用于多列导航中。NavigationSplitView 可以定义两列或三列布局。在较小尺寸下布局 split view 会自动转换为 stack view 布局方式。

// MARK: NavigationSplitView Demo

struct PartyPlannerHome: View {
    @State private var selectedTask: PartyTask?

    var body: some View {
        NavigationSplitView {
            // 左侧导航
            List(PartyTask.allCases, selection: $selectedTask) { task in
                // 使用 `NavigationLink`
                NavigationLink(value: task) {
                    TaskLabel(task: task)
                }
						    .listItemTint(task.color)
            }
        } detail: {
            selectedTask.flatMap { $0.color } ?? .white
        }
    }
}

struct TaskLabel: View {
    let task: PartyTask

    var body: some View {
        Label {
            VStack(alignment: .leading) {
                Text(task.name)
                Text(task.subtitle)
                    .font(.footnote)
                    .foregroundStyle(.secondary)
            }
        } icon: {
            Image(systemName: task.imageName)
                .symbolVariant(.circle.fill)
        }
    }
}

当需要构建更复杂的布局时,可以把 NavigationSplitViewNavigationStack 结合在一起使用

struct PartyPlannerHome: View {
    @State private var selectedTask: PartyTask?

    var body: some View {
        NavigationSplitView {
            List(PartyTask.allCases, selection: $selectedTask) { task in
                NavigationLink(value: task) {
                    TaskLabel(task: task)
                }
                .listItemTint(task.color)
            }
        } detail: {
            if case .food = selectedTask {
                // 嵌入一个基于 `NavigationStack` 的视图(self-contained navigation stack),点击 item 会在当前页面更新
                FoodsListView()
            } else {
                selectedTask.flatMap { $0.color } ?? .white
            }
        }
    }
}

Scenes

已有的 WindowGroup 可以用来构建应用的主视图,创建多个交互窗口。WWDC22 SwiftUI 增加了 Window 视图,一个独立的窗口组件。

管理 Window

通常要打开一个 Window 可以通过以下方式:

  • 窗口菜单找到 Window 的标题并点击
  • 设置快捷键
  • 调用 environment action openWindow
@main
struct PartyPlanner: App {
    var body: some Scene {
        WindowGroup("Party Planner") {
            PartyPlannerHome()
        }

        // 默认情况下在应用的窗口菜单,点击 Window 标题即可打开。
        Window("Party Budget", id: "budget") {
            BudgetView()
        }
        // 或者定义快捷键 `Command+0`
        .keyboardShortcut("0")
    }
}

使用按钮打开 window

struct DetailView: View {
    // 1. 使用 environment aciton
    @Environment(\.openWindow) var openWindow

    var body: some View {
        Text("Detail View")
            .toolbar {
                Button {
                    // 2. 点击打开
                    openWindow(id: "budget")
                } label: {
                    Image(systemName: "dollarsign")
                }
            }
    }
}

自定义 Window 样式

Window 支持默认尺寸、位置、缩放和其他修改器。

@main
struct PartyPlanner: App {
    var body: some Scene {
        WindowGroup("Party Planner") {
            PartyPlannerHome()
        }

        Window("Party Budget", id: "budget") {
            BudgetView()
        }
        .keyboardShortcut("0")
        // 默认位置
        .defaultPosition(.topLeading)
        // 默认尺寸
        .defaultSize(width: 220, height: 250)
    }
}

兼容小屏幕

在这个事例中 Window 很适合展示一小组汇总数据。如果要在跨平台时获得更好的体验,就需要对小尺寸屏幕进行一些优化,例如在 iOS 中可以把 Budget view 放在一个 resizable sheet 内。

struct PartyPlannerHome: View {
    @State private var selectedTask: PartyTask?
    @State private var presented: Bool = false

    var body: some View {
        NavigationSplitView {
            List(PartyTask.allCases, selection: $selectedTask) { task in
                NavigationLink(value: task) {
                    TaskLabel(task: task)
                }
                .listItemTint(task.color)
            }
        } detail: {
            if case .food = selectedTask {
                FoodsListView()
            } else {
                selectedTask.flatMap { $0.color } ?? .white
            }
        }
        .sheet(isPresented: $presented) {
            BudgetView()
                // 设置 detents
                .presentationDetents([.height(250), .medium])
                .presentationDragIndicator(.visible)
        }
    }
}

在 Xcode 内使用 multiplatform targe 发布到多个平台,即可以自动适配。跨平台发布可以查看 WWDC22 的 What's new in Xcode "Use Xcode to develop a multiplatform app" 小节的内容。

Menu bar

在新的 macOS Ventura 系统中,可以创建完全使用 SwiftUI 实现的 MenuBarExtras。这是一个独立于其他 scene 类型,且在应用运行时会常驻 menu bar 的。

@main
struct PartyPlanner: App {
    var body: some Scene {
        Window("Party Budget", id: "budget") {
            Text("Budget View")
        }
        
        // 2. 为 App 添加一个 MenuBarExtra
        MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") {
            BulletinBoard()
        }
        // 3. 定义样式
        .menuBarExtraStyle(.window)
    }
}



private let allPosts: [String] = [
    "Did you know: On your third birthday, you are celebrating your 4.0 release.",
]

// 1. 创建一个用于 menu bar extra 的视图
struct BulletinBoard: View {

    @State var currentPostIndex: Int = 0

    var currentPost: String {
        allPosts[currentPostIndex]
    }

    var body: some View {
        VStack(spacing: 16) {

            VStack(spacing: 12) {
                HStack(alignment: .firstTextBaseline) {
                    Text("“")
                        .font(.custom("Helvetica", size: 50).bold())
                        .baselineOffset(-23)
                        .foregroundStyle(.tertiary)

                    Text("Party Bulletin Board")
                        .font(.headline.weight(.semibold))
                        .foregroundStyle(.secondary)

                    Spacer()

                    Text("June 6, 2022")
                        .font(.headline.weight(.regular))
                        .foregroundStyle(.secondary)
                }
                .frame(height: 20)


                Text(currentPost)
                    .font(.system(size: 18))
                    .multilineTextAlignment(.center)
            }
            .padding(.bottom, 4)

            Divider()

            HStack {
                Button {

                } label: {
                    Label("Calendar", systemImage: "calendar")
                }
                Button {
                    currentPostIndex = (currentPostIndex + 1) % allPosts.count
                } label: {
                    Text("Previous")
                        .frame(maxWidth: .infinity)
                }

                ShareLink(items: [currentPost])
            }
            .labelStyle(.iconOnly)
            .controlSize(.large)
        }
        .padding(16)
    }
}

甚至可以只使用 MenuBarExtras 构建一个应用。

@main
struct MessageBoard: App {
    var body: some Scene {
        MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") {
            BulletinBoard()
        }
        .menuBarExtraStyle(.window)
    }
}

要想了解更多关于新的 scene 类型和功能,可以访问 WWDC22 的 Bring Multiple Windows to Your SwiftUI App