Rust in Everything
#SwiftUI#Swift#iOS

SwiftUI Form 教程:使用 Picker、Toggle 和 Stepper 构建表单

在上一章节中,我们已经学习了如何使用 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()
}

上面代码主要包含了以下几个部分:

  1. NavigationStack:提供导航功能并设置标题为 “Setting”。
  2. Form:在 SwiftUI 中用于创建表单容器。
  3. 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)内逐步调节数值。实现思路如下:

  1. 用 @State private var maxPriceLevel = 5 初始化一个整型变量。
  2. 在 Stepper 中指定 onIncrement 和 onDecrement 闭包,分别执行 self.maxPriceLevel += 1 和 self.maxPriceLevel -= 1 操作。
  3. 为了不让 maxPriceLevel 超过范围,需要做边界检查。例如最大值 5、最小值 1。
  4. 在 Stepper 的文本中动态展示相应数量的 $ 符号,使界面一目了然。

当用户点击加号或减号按钮时,maxPriceLevel 会自动更新。任何依赖这个值的界面元素都会实时刷新。


5. 在主页面展示 SettingView(模态视图)

在 ContentView 中,我们可以通过模态视图(sheet)来展示上述的 SettingView。以下是示例代码的核心思路:

  1. 声明一个 @State private var showSetting: Bool = false 变量,用于记录模态视图是否显示。
  2. 在导航栏添加一个齿轮图标按钮,点击时切换 showSetting 为 true:
    .toolbar {
      ToolbarItem(placement: .navigationBarTrailing) {
        Button(action: {
          self.showSetting = true
        }) {
          Image(systemName: "gear")
            .font(.title)
        }
        .tint(.black)
      }
    }
    
  3. 使用 .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 打下坚实的基础。

评论区

登录后即可发表评论

加载评论中…

💬 关联讨论

发起讨论
加载中...