WWDC22: SwiftUI 的新功能 part 2
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
Picker
和 Toggle
一样支持 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
}