在上一章节中,我们已经学习了如何使用 Sheet 与 FullScreenCover 来实现模态视图。
本章将探讨另一种常见的交互方式——表单(Form)。
在移动应用开发中,表单是最常见的交互方式之一。 无论是日常在 iPhone 上创建日历事件,还是在购物类应用中填写收货与支付信息,都离不开表单。 对于开发者而言,如何快速而优雅地构建一个表单界面,是一项非常重要的技能。
在 SwiftUI 框架中,我们可以使用内置的 Form
组件来轻松搭建表单界面,
并结合 Picker
、Toggle
、Stepper
等常用控件,收集并管理用户输入。
本文将为你详细讲解如何使用这些组件来构建一个设置(Settings)页面,
并在其中实现排序偏好、筛选偏好等常见功能。
完成后,你就可以在自己的项目中灵活运用表单来处理各种输入需求。
功能介绍
我们将以一个 Restaurant 应用的“设置”页面为例,使用 SwiftUI 构建一个表单界面。
该设置页面包含以下功能:
- 排序偏好(Sort Preference)
提供三种餐厅显示顺序选项的选择,包括「Alphabetical」「Show Favorite First」「Show Check-in First」。 - 筛选偏好(Filter Preference)
- 是否只显示已 Check-in 的餐厅(Toggle)。
- 设置餐厅最高消费价格(Stepper),范围为 1~5。
完成这几个功能后,我们还会演示如何将这个设置视图作为模态视图呈现,并在界面上添加“设置”按钮来唤起或关闭此模态窗口。
提示: 目前,本示例仅演示如何实现表单界面及收集表单数据,尚未实现用户偏好数据的持久化和在列表中应用过滤逻辑。 后续你可以进一步拓展:将用户偏好存储至本地,同时根据用户偏好 对餐厅列表进行实时筛选和排序。
1. 使用 Form 构建表单结构
SwiftUI 提供的 Form
组件可以将多个输入控件分组到不同的 Section 中,从而形成一个结构清晰的表单。
以下示例代码演示了如何在单独的 SettingView
中使用 Form
来搭建一个初始的设置界面,
并将其放在 NavigationStack
中:
struct SettingView: View {
private var displayOrders = ["Alphabetical", "Show Favorite First", "Show Check-in First"]
@State private var selectedOrder = 0
@State private var showCheckInOnly = false
@State private var maxPriceLevel = 5
var body: some View {
NavigationStack {
Form {
Section(header: Text("SORT PREFERENCE")) {
Picker(selection: $selectedOrder, label: Text("Display order")) {
ForEach(0 ..< displayOrders.count, id: \.self) {
Text(self.displayOrders[$0])
}
}
}
Section(header: Text("FILTER PREFERENCE")) {
Toggle(isOn: $showCheckInOnly) {
Text("Show Check-in Only")
}
Stepper(onIncrement: {
self.maxPriceLevel += 1
if self.maxPriceLevel > 5 {
self.maxPriceLevel = 5
}
}, onDecrement: {
self.maxPriceLevel -= 1
if self.maxPriceLevel < 1 {
self.maxPriceLevel = 1
}
}) {
Text("Show \(String(repeating: \"$\", count: maxPriceLevel)) or below")
}
}
}
.navigationBarTitle("Setting")
.navigationBarItems(leading: Button(action: {
dismiss()
}) {
Text("Cancel")
}, trailing: Button(action: {
dismiss()
}) {
Text("Done")
})
}
}
}
#Preview {
SettingView()
}
上面代码主要包含了以下几个部分:
NavigationStack
:提供导航功能并设置标题为 “Setting”。Form
:在 SwiftUI 中用于创建表单容器。Section
:对表单内容进行分组,这里演示了两个分组:SORT PREFERENCE
和FILTER PREFERENCE
。
这样,一个初步的设置界面就完成了,我们可以在 Canvas 或模拟器中查看到相应的 UI。
注意: 在文中给出的原始代码中出现了一个小问题,
Section(header: Text("STOP PREFERENCE"))
应该改为Section(header: Text("SORT PREFERENCE"))
,否则与实际需求不符。
2. 实现 Picker 功能
在“排序偏好”(Sort Preference)部分,我们使用了 Picker
来让用户在多个选项中选择一个。
示例代码中的关键点如下:
- 声明一个存储可选排序方式的数组
displayOrders
。 - 用
@State private var selectedOrder = 0
来追踪用户选中了哪一个选项(初始值0
对应数组的第一个元素)。 - 使用
Picker
并绑定$selectedOrder
,在闭包中遍历数组并显示在界面上。
一旦在界面上选择了不同的排序方式,selectedOrder
会自动更新,帮助我们在后续业务中使用。
3. 使用 Toggle 控制开关
在“筛选偏好”(Filter Preference)部分的第一个功能是决定是否只显示已 Check-in 的餐厅。 这可以用开关(Toggle)实现:
@State private var showCheckInOnly = false
Toggle(isOn: $showCheckInOnly) {
Text("Show Check-in Only")
}
当用户切换这个开关时,showCheckInOnly
的布尔值会在 true
和 false
之间切换。
SwiftUI 会自动对控件进行更新,而我们只需在后续逻辑中根据这个值来过滤列表或执行其他操作。
4. 使用 Stepper 调节数值范围
本示例还包括了一个价格筛选器,让用户指定餐厅的最高消费水平。 Stepper 提供了加减按钮,方便用户在限定范围(比如 1~5)内逐步调节数值。实现思路如下:
- 用
@State private var maxPriceLevel = 5
初始化一个整型变量。 - 在 Stepper 中指定
onIncrement
和onDecrement
闭包,分别执行self.maxPriceLevel += 1
和self.maxPriceLevel -= 1
操作。 - 为了不让
maxPriceLevel
超过范围,需要做边界检查。例如最大值 5、最小值 1。 - 在 Stepper 的文本中动态展示相应数量的
$
符号,使界面一目了然。
当用户点击加号或减号按钮时,maxPriceLevel
会自动更新。任何依赖这个值的界面元素都会实时刷新。
5. 在主页面展示 SettingView(模态视图)
在 ContentView
中,我们可以通过模态视图(sheet)来展示上述的 SettingView
。以下是示例代码的核心思路:
- 声明一个
@State private var showSetting: Bool = false
变量,用于记录模态视图是否显示。 - 在导航栏添加一个齿轮图标按钮,点击时切换
showSetting
为true
:.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
self.showSetting = true
}) {
Image(systemName: "gear")
.font(.title)
}
.tint(.black)
}
} - 使用
.sheet(isPresented: $showSetting)
修饰符来弹出SettingView
:.fullScreenCover(isPresented: $showSetting) {
SettingView()
}
下面展示了完整的 ContentView
代码示例(包含一个示例的餐厅列表与简易增删收藏操作):
import SwiftUI
struct ContentView: View {
@State var restaurants = [
Restaurant(name: "Cafe Deadend", type: "Coffee & Tea Shop", phone: "232-923423", image: "cafedeadend", priceLevel: 3),
Restaurant(name: "Homei", type: "Cafe", phone: "348-233423", image: "homei", priceLevel: 3),
Restaurant(name: "Teakha", type: "Tea House", phone: "354-243523", image: "teakha", priceLevel: 3, isFavorite: true, isCheckIn: true),
Restaurant(name: "Cafe loisl", type: "Austrian / Casual Drink", phone: "453-333423", image: "cafeloisl", priceLevel: 2, isFavorite: true, isCheckIn: true),
Restaurant(name: "Petite Oyster", type: "French", phone: "983-284334", image: "petiteoyster", priceLevel: 5, isCheckIn: true),
Restaurant(name: "For Kee Restaurant", type: "Hong Kong", phone: "232-434222", image: "forkeerestaurant", priceLevel: 2, isFavorite: true, isCheckIn: true),
Restaurant(name: "Po's Atelier", type: "Bakery", phone: "234-834322", image: "posatelier", priceLevel: 4),
Restaurant(name: "Bourke Street Backery", type: "Chocolate", phone: "982-434343", image: "bourkestreetbakery", priceLevel: 4, isCheckIn: true),
Restaurant(name: "Haigh's Chocolate", type: "Cafe", phone: "734-232323", image: "haighschocolate", priceLevel: 3, isFavorite: true),
Restaurant(name: "Palomino Espresso", type: "American / Seafood", phone: "872-734343", image: "palominoespresso", priceLevel: 2),
Restaurant(name: "Upstate", type: "Seafood", phone: "343-233221", image: "upstate", priceLevel: 4),
Restaurant(name: "Traif", type: "American", phone: "985-723623", image: "traif", priceLevel: 5),
Restaurant(name: "Graham Avenue Meats", type: "Breakfast & Brunch", phone: "455-232345", image: "grahamavenuemeats", priceLevel: 3),
Restaurant(name: "Waffle & Wolf", type: "Coffee & Tea", phone: "434-232322", image: "wafflewolf", priceLevel: 3),
Restaurant(name: "Five Leaves", type: "Bistro", phone: "343-234553", image: "fiveleaves", priceLevel: 4, isFavorite: true, isCheckIn: true),
Restaurant(name: "Cafe Lore", type: "Latin American", phone: "342-455433", image: "cafelore", priceLevel: 2, isFavorite: true, isCheckIn: true),
Restaurant(name: "Confessional", type: "Spanish", phone: "643-332323", image: "confessional", priceLevel: 4),
Restaurant(name: "Barrafina", type: "Spanish", phone: "542-343434", image: "barrafina", priceLevel: 2, isCheckIn: true),
Restaurant(name: "Donostia", type: "Spanish", phone: "722-232323", image: "donostia", priceLevel: 1),
Restaurant(name: "Royal Oak", type: "British", phone: "343-988834", image: "royaloak", priceLevel: 2, isFavorite: true),
Restaurant(name: "CASK Pub and Kitchen", type: "Thai", phone: "432-344050", image: "caskpubkitchen", priceLevel: 1)
]
@State private var selectedRestaurant: Restaurant?
@State private var showSetting: Bool = false
var body: some View {
NavigationStack {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
Button(action: {
// mark the selected restaurant as check-in
self.checkIn(item: restaurant)
}) {
HStack {
Text("Check-in")
Image(systemName: "checkmark.seal.fill")
}
}
Button(action: {
// delete the selected restaurant
self.delete(item: restaurant)
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
Button(action: {
// mark the selected restaurant as favorite
self.setFavorite(item: restaurant)
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
}
.onTapGesture {
self.selectedRestaurant = restaurant
}
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.navigationTitle("Restaurant")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
self.showSetting = true
}) {
Image(systemName: "gear")
.font(.title)
}
.tint(.black)
}
}
.sheet(isPresented: $showSetting) {
SettingView()
}
}
}
private func delete(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants.remove(at: index)
}
}
private func setFavorite(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants[index].isFavorite.toggle()
}
}
private func checkIn(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants[index].isCheckIn.toggle()
}
}
}
#Preview {
ContentView()
}
struct Restaurant: Identifiable {
var id = UUID()
var name: String
var type: String
var phone: String
var image: String
var priceLevel: Int
var isFavorite: Bool = false
var isCheckIn: Bool = false
}
struct BasicImageRow: View {
var restaurant: Restaurant
var body: some View {
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 60, height: 60)
.clipShape(Circle())
.padding(.trailing, 10)
VStack(alignment: .leading) {
HStack {
Text(restaurant.name)
.font(.system(.body, design: .rounded))
.bold()
Text(String(repeating: "$", count: restaurant.priceLevel))
.font(.subheadline)
.foregroundColor(.gray)
}
Text(restaurant.type)
.font(.system(.subheadline, design: .rounded))
.bold()
.foregroundColor(.secondary)
.lineLimit(3)
Text(restaurant.phone)
.font(.system(.subheadline, design: .rounded))
.foregroundColor(.secondary)
}
Spacer()
.layoutPriority(-100)
if restaurant.isCheckIn {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.red)
}
if restaurant.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}
}
结语
通过本教程,你学习了如何运用 SwiftUI 提供的 Form
组件来搭建表单界面,
并结合 Picker
、Toggle
与 Stepper
等常见控件收集用户输入。
掌握了表单的布局方式后,你还可以根据实际业务需要,增添各种自定义输入控件、数据校验逻辑或进一步的持久化功能。
在下一个阶段,你可以深入研究以下内容:
- 数据持久化:将用户选定的设置保存在本地,比如使用
UserDefaults
或Core Data
。 - 数据联动:根据用户选择的排序和筛选方式,实时更新并刷新餐厅列表。
- 环境变量与共享数据:使用
@Environment
或ObservableObject
在多个视图之间共享状态。
希望这篇文章能帮助你更好地理解并上手 SwiftUI 表单开发,为你的下一款 iOS App 打下坚实的基础。