WWDC22: SwiftUI 的新功能 part 2

发布
更新
字数 1578
阅读 8 分钟
阅读量 849

Advanced controls

在开发 App 时经常会遇到表单视图,如控制面板、软件设置,按以往个人经验都是用 table view 或者新的 collection view 结合 list style 实现,在有了 diffable datasource 和 compositional layout 之后,视图布局变得更容易和可控了,但涉及到多种表单组件以及数据同步时,就会有大量的编码工作。

SwiftUI 在 WWDC22 更新了表单布局视图 Form 以及一些常用的控件如 LabeledContent Toggle 等,SwiftUI 与生俱来的响应式编程方式,让数据绑定和同步变得非常简单。

Forms

表单项使用 Section 进行分组,静态内容使用 LabeledContent 视图,可以在闭包 builder 中构建自定义内容,其他动态控件可以通过 selection: 参数绑定数据。Form 通过声明式 API formStyle() 设置 Section 分组样式。

Form {
    // 使用 Section 分组
    Section {
        // Location 是静态的
        LabeledContent("Location", value: address)
        // 绑定 `selection` 为 `$date`
        DatePicker("Date", selection: $date)
        TextField("Description", text: $eventDescription, axis: .vertical)
            .lineLimit(3, reservesSpace: true)
    }
    
    // Section 标题是 Vibe
    Section("Vibe") {
        Picker("Accent color", selection: $accent) {
            // 构建 picker 的内容
            ForEach(Theme.allCases) { theme in
                Text(theme.rawValue.capitalized).tag(theme)
            }
        }
        Picker("Color scheme", selection: $scheme) {
            Text("Light").tag(ColorScheme.light)
            Text("Dark").tag(ColorScheme.dark)
        }
        // SwiftUI 会自动布局,第一个 Text 会作为主标题,第二个 Text 作为描述
        Toggle(isOn: $extraGuests) {
            Text("Allow extra guests")
            Text("The more the merrier!")
        }
    }
}
// 分组样式
.formStyle(.grouped)

Form 会自动布局,例如对齐、字体样式。 LabeledContent 可以通过 value: 参数传入一个值,也可以使用自定义视图

LabeledContent("Location") {
    AddressView(location)
}

此外同样的代码可以同时发布到不同的平台,系统会自动渲染为对应的样式,并做出优化,例如 iOS 上为触屏,可能需要更大的热区响应点击操作。

Controls

TextField

TextField 支持多行文本,并且可以通过 axis 属性设置自动扩展,并通过 lineLimit 修改器设置扩展范围(👍)。

// 可以竖向扩展的文本框
TextField("Description", text: $description, axis: .vertical)
// 显示占位最少 5 行,并可扩展到 10 行
.lineLimit(5...10)

MultiDatePicker

支持选择多个日期,非连续的,查看文档:https://developer.apple.com/documentation/swiftui/multidatepicker

@State private var activityDates: Set<DateComponents>

var body: some View {
    MultiDatePicker("Dates", selection: $activityDates)
}

Mixed-state controls

Toggle

类似于多级复选框,Toggle 可以包含一系列下级选框,并把结果合并到父级。

DisclosureGroup {
    // 一组 Toggle
    HStack {
        Toggle("Balloons 🎈", isOn: $includeBalloons)
        Spacer()
        decorationThemes[.balloon].map { $0.swatch }
    }
    .tag(Decoration.balloon)

    HStack {
        Toggle("Confetti 🎊", isOn: $includeConfetti)
        Spacer()
        decorationThemes[.confetti].map { $0.swatch }
    }
    .tag(Decoration.confetti)

    HStack {
        Toggle("Inflatables 🪅", isOn: $includeInflatables)
        Spacer()
        decorationThemes[.inflatables].map { $0.swatch }
    }
    .tag(Decoration.inflatables)

    HStack {
        Toggle("Party Horns 🥳", isOn: $includeBlowers)
        Spacer()
        decorationThemes[.noisemakers].map { $0.swatch }
    }
    .tag(Decoration.noisemakers)
} label: {
    // 把结果合并到一个 Toggle 中,类似“全选”操作
    Toggle("All Decorations", isOn: [
        $includeBalloons,
        $includeConfetti,
        $includeInflatables,
        $includeBlowers
    ])
    .tag(Decoration.all)
}
// Mac 上显示为复选框
#if os(macOS)
.toggleStyle(.checkbox)
#endif

Picker

PickerToggle 一样支持 mixed-state

@State private var selectedDecorations: [Decoration] = []
@State private var decorationThemes: [Decoration: Theme] = [
    .balloon : .blue,
    .confetti: .gold,
    .inflatables: .black,
    .noisemakers: .white,
    .none: .black
]
private var themes: [Binding<Theme>] {
    if selectedDecorations.count == 0 {
        return [Binding($decorationThemes[.none])!]
    }
    return selectedDecorations.compactMap {
        Binding($decorationThemes[$0])
    }
}

List(selection: $selectedDecorations) {
    DisclosureGroup {
        HStack {
            Toggle("Balloons 🎈", isOn: $includeBalloons)
            Spacer()
            decorationThemes[.balloon].map { $0.swatch }
        }
        .tag(Decoration.balloon)

        HStack {
            Toggle("Confetti 🎊", isOn: $includeConfetti)
            Spacer()
            decorationThemes[.confetti].map { $0.swatch }
        }
        .tag(Decoration.confetti)

        HStack {
            Toggle("Inflatables 🪅", isOn: $includeInflatables)
            Spacer()
            decorationThemes[.inflatables].map { $0.swatch }
        }
        .tag(Decoration.inflatables)

        HStack {
            Toggle("Party Horns 🥳", isOn: $includeBlowers)
            Spacer()
            decorationThemes[.noisemakers].map { $0.swatch }
        }
        .tag(Decoration.noisemakers)
    } label: {
        Toggle("All Decorations", isOn: [
            $includeBalloons, $includeConfetti,
            $includeInflatables, $includeBlowers
        ])
        .tag(Decoration.all)
    }
    #if os(macOS)
    .toggleStyle(.checkbox)
    #endif
}

// Picker 会反映选中的 Decoration 样式
Picker("Decoration theme", selection: themes) {
    Text("Blue").tag(Theme.blue)
    Text("Black").tag(Theme.black)
    Text("Gold").tag(Theme.gold)
    Text("White").tag(Theme.white)
}
#if os(macOS)
.pickerStyle(.radioGroup)
#endif

Button styles

现在所有支持按钮样式的控件都可以通过 buttonStyle() 设置一个按钮样式,如 Toggle Menu 及各种 Picker 等。

Stepper

支持对值进行格式化

Stepper("Guest limit", value: $guestLimit, format: .number)

在 macOS Stepper 支持直接编辑,同时 watchOS 也支持该控件。

Accessibility quick actions

在 Apple watch 上可以使用 quick actions 控制按钮,直接复用已有组件即可。

@State private var isInCart: Bool = false

var body: some View {
    VStack(alignment: .leading) {
        ItemDescriptionView()
        // 一般显示一个按钮
        addToCartButton
    }
    // 支持手表 quick action
    .accessibilityQuickAction {
        // 同一个按钮
        addToCartButton
    }
}

// 可复用的按钮组件
var addToCartButton: some View {
    Button(isInCart ? "Remove from cart" : "Add to cart") {
        isInCart.toggle()
    }
}
    

Tables

Table API 可以兼容 macOS, iPadOS,在 iOS 下会仅显示主列。

Table(attendees) {
    TableColumn("Name") { attendee in
        AttendeeRow(attendee)
    }
    TableColumn("City", value: \.city)
    TableColumn("Status") { attendee in
        StatusRow(attendee)
    }
}

contextMenu

通过为 Table添加 contextMenu 修饰符添加上下文菜单,当选中表格或空白位置时执行常见操作。新的

Table(attendees) {
    TableColumn("Name") { attendee in
        AttendeeRow(attendee)
    }
    TableColumn("City", value: \.city)
    TableColumn("Status") { attendee in
        StatusRow(attendee)
    }
}
.contextMenu(forSelectionType: Attendee.ID.self) {
    selection in
    if selection.isEmpty {
        // 选中空白区域
        Button("New Invitation") { addInvitation() }
    } else if selection.count == 1 {
        // 单选
        Button("Mark as VIP") { markVIPs(selection) }
    } else {
        // 多选
        Button("Mark as VIPs") { markVIPs(selection) }
    }
}

iPad 上的 Toolbar 工具栏

上下文菜单很方便,当在 iPad 上建议使用工具栏项,以按钮的形式显示出来会更加直观,易于发现。而且 iPad 上的工具栏现在也支持自定义和重新排序。

Table(attendeeStore.attendees, selection: $selection) {
    TableColumn("Name") { attendee in
        AttendeeRow(attendee)
    }
    TableColumn("City", value: \.city)
    TableColumn("Status") { attendee in
        StatusRow(attendee)
    }
}
// 修改一下,仅在 macOS 上显示上下文菜单
#if os(macOS)
.contextMenu(forSelectionType: Attendee.ID.self) { selection in
    if selection.isEmpty {
        Button("New Invitation") { addInvitation() }
    } else if selection.count == 1 {
        Button("Mark as VIP") { markVIPs(selection) }
    } else {
        Button("Mark as VIPs") { markVIPs(selection) }
    }
}
#endif
// 同时会显示工具栏
.toolbar(id: "toolbar") {
    // 为工具栏项提供一个显式的标识符,用于支持排序和自定义
    // 使用新的工具栏项位置 `.secondaryAction` 标明是可定制的操作
    ToolbarItem(id: "new", placement: .secondaryAction) {
        Button(action: {}) {
            Label("New Invitation", systemImage: "envelope")
        }
    }
    ToolbarItem(id: "edit", placement: .secondaryAction) {
        Button(action: {}) {
            Label("Edit", systemImage: "pencil.circle")
        }
    }
    ToolbarItem(id: "share", placement: .secondaryAction) {
        Button(action: {}) {
            Label("Share", systemImage: "square.and.arrow.up")
        }
    }
    ToolbarItem(id: "tag", placement: .secondaryAction) {
        Button(action: {}) {
            Label("Tags", systemImage: "tag")
        }
    }
    ToolbarItem(
        id: "reminder", placement: .secondaryAction, showsByDefault: false
    ) {
        Button(action: {}) {
            Label("Set reminder", systemImage: "bell")
        }
    }
}
.toolbarRole(.editor)

搜索 Search token and scope

新的搜索功能为了实现结构化的搜索查询,搜索字段支持标记化的输入和建议。

Table { ... }
.searchable(
    text: $query, tokens: $tokens, scope: $scope
) { token in
    Label(
        token.query,
        systemImage: token.systemImage)
} scopes: {
    // 提供分段控件,使用户可以选择搜索的范围
    Text("In Person").tag(AttendanceScope.inPerson)
    Text("Online").tag(AttendanceScope.online)
} suggestions: {
    suggestions
}