上一章节 介绍了 SwiftUI 和 Combine 的结合使用, 本章将进一步扩展这个功能,深入探讨如何让用户与列表产生更多交互。
今天,我们将学习如何在 SwiftUI 中为列表添加左滑删除、长按触发上下文菜单和点击弹出 Action Sheet 等互动功能。
具体来说,您将学到:
- 左滑删除(Swipe-to-delete)
- 点击某一行弹出 Action Sheet
- 长按某一行呼出 Context Menu
其中,左滑删除和 Action Sheet 都是 iOS 多年来所熟悉的界面元素, 而从 iOS 13 开始,Apple 引入了类似 3D Touch “peek & pop”的 Context Menu 功能, 使得用户可以通过长按或 3D Touch(设备支持的情况下)来触发一个弹出菜单。 作为开发者,我们需要配置在这个菜单中出现的操作项。
提示:虽然本章主要演示在
List
中实现这些互动技巧,但类似的做法也可应用于其他 UI 控件(例如Button
等)。
准备 Starter Project
我们将基于一个简单的餐厅列表 App 来演示这些功能。
- 左滑删除功能
- Action Sheet
- Context Menu
基础项目代码如下:
import SwiftUI
struct ContentView: View {
var restaurants = [
Restaurant(name: "Cafe Deadend", image: "cafedeadend"),
Restaurant(name: "Homei", image: "homei"),
Restaurant(name: "Teakha", image: "teakha"),
Restaurant(name: "Cafe Loisl", image: "cafeloisl"),
Restaurant(name: "Petite Oyster", image: "petiteoyster"),
Restaurant(name: "For Kee Restaurant", image: "forkeerestaurant"),
Restaurant(name: "Po's Atelier", image: "posatelier"),
Restaurant(name: "Bourke Street Bakery", image: "bourkestreetbakery"),
Restaurant(name: "Haigh's Chocolate", image: "haighschocolate"),
Restaurant(name: "Palomino Espresso", image: "palominoespresso"),
Restaurant(name: "Homei", image: "upstate"),
Restaurant(name: "Traif", image: "traif"),
Restaurant(name: "Graham Avenue Meats And Deli", image: "grahamavenuemeats"),
Restaurant(name: "Waffle & Wolf", image: "wafflewolf"),
Restaurant(name: "Five Leaves", image: "fiveleaves"),
Restaurant(name: "Cafe Lore", image: "cafelore"),
Restaurant(name: "Confessional", image: "confessional"),
Restaurant(name: "Barrafina", image: "barrafina"),
Restaurant(name: "Donostia", image: "donostia"),
Restaurant(name: "Royal Oak", image: "royaloak"),
Restaurant(name: "CASK Pub and Kitchen", image: "caskpubkitchen")
]
var body: some View {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
}
.listStyle(.plain)
}
}
#Preview {
ContentView()
}
struct Restaurant: Identifiable {
var id = UUID()
var name: String
var image: String
}
struct BasicImageRow: View {
var restaurant: Restaurant
var body: some View {
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
}
}
}
实现 Swipe-to-Delete
确保你已经打开了示例项目后,让我们先从左滑删除功能入手。
在 SwiftUI 中,只需要对数据源所在的 ForEach
添加 onDelete
处理器,
即可为列表所有行启用左滑删除。
示例如下:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
在 onDelete
闭包中,我们通过传入的 indexSet
(它记录了要删除的行的索引)
来调用 remove(atOffsets:)
,从 restaurants
数组中移除对应的项目。
为了让删除操作后 UI 自动更新,需要借助 @State
来修饰存储列表数据的变量,
以便 SwiftUI 能监听到数据变化并自动刷新界面。
示例项目中,你可以在 ContentView
里将 restaurants
定义成这样:
@State var restaurants = [ ... ]
编译并运行后,就可以体验到左滑删除了。往左一划,就能看到 Delete
按钮。
点击后,对应那一行就会带着一个漂亮的动画消失,这个动画是 SwiftUI 自动生成的,
无需任何额外的动画代码。
如果你曾用 UIKit 来实现过这个功能,就会对 SwiftUI 的简洁深有体会: 仅需几行代码就能搞定左滑删除。
创建 Context Menu
接下来,我们来看看 Context Menu。它很像 3D Touch 里的 “peek & pop”, 不过有一个优点:即使设备不支持 3D Touch 也能用,而且只要长按就可以触发,真正做到了适配更多设备。
在 SwiftUI 中,只需为某个视图添加 contextMenu
修饰器,并在其中配置要展示的菜单项即可。
我们想在长按某行时弹出两项操作:Delete 和 Favorite。下面是示例代码(仅展示关键部分):
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
Button(action: {
// delete the selected restaurant
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
Button(action: {
// mark the selected restaurant as favorite
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
}
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
此时,如果你运行并长按某行,就能看到包含 “Delete” 和 “Favorite” 的弹出菜单,
不过按钮的功能尚未实现。要处理删除逻辑,跟 onDelete
不同,contextMenu
并不会直接告诉你被选中行的索引。我们可以先写一个辅助函数来完成删除操作:
private func delete(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants.remove(at: index)
}
}
这样,在 contextMenu
的 “Delete” 按钮点击事件中调用 self.delete(item: restaurant)
即可:
Button(action: {
self.delete(item: restaurant)
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
再次运行 App,长按某行并选择 Delete
就能删除对应的餐厅条目。
实现 Favorite 功能
同理,如果要给某行打上 “Favorite” 标记,可以先在 Restaurant
结构里增加一个布尔字段 isFavorite
:
struct Restaurant: Identifiable {
var id = UUID()
var name: String
var image: String
var isFavorite: Bool = false
}
接着,在 ContentView
中新增一个方法,用来切换某个餐厅的 “Favorite” 状态:
private func setFavorite(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants[index].isFavorite.toggle()
}
}
最后,在列表展示项里,如果餐厅被标记为 “Favorite”,就显示一个星星图标。更新 BasicImageRow
中的代码如下:
struct BasicImageRow: View {
var restaurant: Restaurant
var body: some View {
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
if restaurant.isFavorite {
Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}
}
别忘了在 contextMenu
里,给 “Favorite” 按钮的 action
里调用 setFavorite(item: restaurant)
:
Button(action: {
self.setFavorite(item: restaurant)
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
编译运行后,长按某行选择 Favorite,就会在那一行的末尾出现一颗小星星图标。
使用 Action Sheets
在上一节,我们使用了 Context Menu。实际上,很多应用也会使用 Action Sheet 来进行类似的操作, 例如:当用户点击某行时,从屏幕底部弹出一个选择操作的面板。如果不记得 Action Sheet 是什么样子, 可以回看之前的图示。
SwiftUI 提供了 ActionSheet
视图来创建这种操作面板,常见调用方式如下:
ActionSheet(
title: Text("What do you want to do"),
message: nil,
buttons: [.default(Text("Delete"))]
)
title
和message
用于设置标题和可选的提示信息。buttons
是一个按钮数组,可配置多种类型的按钮。
要触发 Action Sheet,可以对某个视图(比如某行)使用 actionSheet
修饰器,
SwiftUI 提供了两种方式来管理 Action Sheet 的显示与隐藏:
- 通过
isPresented
来绑定一个布尔值。 - 通过可选的
Identifiable
对象来触发(只要对象存在即显示)。
方式一:使用 isPresented
先在 ContentView
中定义两个变量,一个控制面板是否显示,一个存储被选中的餐厅:
@State private var showActionSheet = false
@State private var selectedRestaurant: Restaurant?
然后给 List
里的每一行加上 onTapGesture
,当用户点击时,设置 showActionSheet
为 true
,并记录被点击的餐厅:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
// Context Menu 配置
}
.onTapGesture {
self.showActionSheet.toggle()
self.selectedRestaurant = restaurant
}
.actionSheet(isPresented: self.$showActionSheet) {
ActionSheet(
title: Text("What do you want to do"),
message: nil,
buttons: [
.default(Text("Mark as Favorite"), action: {
if let selectedRestaurant = self.selectedRestaurant {
self.setFavorite(item: selectedRestaurant)
}
}),
.destructive(Text("Delete"), action: {
if let selectedRestaurant = self.selectedRestaurant {
self.delete(item: selectedRestaurant)
}
}),
.cancel()
]
)
}
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
在上述代码中,当 showActionSheet
改变为 true
时,ActionSheet
就会被触发并显示。
这里我们展示了三种类型的按钮:
.default
:普通默认按钮。.destructive
:带有红色字体的危险操作按钮,如删除。.cancel
:用于关闭 Action Sheet 的取消按钮。
方式二:使用 Identifiable
绑定
第二种方法通过传递一个遵守 Identifiable
协议的可选对象给 actionSheet(item:)
,
只要该对象不为 nil
,Action Sheet 就会被弹出。我们的 selectedRestaurant
本身是可选(可为 nil
) ,并且实现了 Identifiable
,所以可以直接写:
.actionSheet(item: self.$selectedRestaurant) { restaurant in
ActionSheet(
title: Text("What do you want to do"),
message: nil,
buttons: [
.default(Text("Mark as Favorite"), action: {
self.setFavorite(item: restaurant)
}),
.destructive(Text("Delete"), action: {
self.delete(item: restaurant)
}),
.cancel()
]
)
}
这样就不需要再定义一个 showActionSheet
布尔变量了。
只要把选定的餐厅赋值给 selectedRestaurant
,就会触发 Action Sheet。
当用户点击取消或完成操作后,可以在操作里设置 selectedRestaurant = nil
,
以关闭 Action Sheet 并重置状态。
添加 Check-in 功能
学完上述内容,来做个小练习吧:在 Context Menu 中再添加一项 Check-in。
当用户选择此操作时,在对应餐厅项后面显示一个 “签到” 图标,
比如你可以使用 checkmark.seal.fill
。根据下面的示例代码(或最终完整代码),
给 Restaurant
添加一个 isCheckIn
字段,并在 contextMenu
里为每个
餐厅行增加一个 “Check-in” 按钮,最后别忘了在行布局里根据 isCheckIn
的值显示对应图标。
小提示:你只需对照
isFavorite
的实现方式即可,很容易上手。
struct Restaurant: Identifiable {
var id = UUID()
var name: String
var image: String
var isFavorite: Bool = false
var isCheckIn: Bool = false
}
创建 isCheckIn
字段后,在 BasicImageRow
中添加一个 if
条件,
根据 isCheckIn
的值来显示 “Check-in” 图标。
struct BasicImageRow: View {
var restaurant: Restaurant
var body: some View {
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
if restaurant.isCheckIn {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(.red)
}
}
}
}
添加 “Check-in” 按钮后,在 contextMenu
里为每个餐厅行增加一个 “Check-in” 按钮,
最后别忘了在行布局里根据 isCheckIn
的值显示对应图标。
Button(action: {
self.setCheckIn(item: restaurant)
}) {
HStack {
Text("Check-in")
Image(systemName: "checkmark.seal.fill")
}
}