跳到主要内容

在 SwiftUI 中使用 Swipe-to-Delete、Context Menu 和 Action Sheets 详解

鱼雪

上一章节 介绍了 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 修饰器,并在其中配置要展示的菜单项即可。 我们想在长按某行时弹出两项操作:DeleteFavorite。下面是示例代码(仅展示关键部分):

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"))]
)
  • titlemessage 用于设置标题和可选的提示信息。
  • buttons 是一个按钮数组,可配置多种类型的按钮。

要触发 Action Sheet,可以对某个视图(比如某行)使用 actionSheet 修饰器, SwiftUI 提供了两种方式来管理 Action Sheet 的显示与隐藏:

  1. 通过 isPresented 来绑定一个布尔值。
  2. 通过可选的 Identifiable 对象来触发(只要对象存在即显示)。

方式一:使用 isPresented

先在 ContentView 中定义两个变量,一个控制面板是否显示,一个存储被选中的餐厅:

@State private var showActionSheet = false
@State private var selectedRestaurant: Restaurant?

然后给 List 里的每一行加上 onTapGesture,当用户点击时,设置 showActionSheettrue,并记录被点击的餐厅:

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")
}
}

完整代码示例

下面给出一份包含 Swipe-to-DeleteContext MenuAction Sheet 以及 Check-in 功能的完整示例代码,供参考和验证:

import SwiftUI

struct ContentView: View {
@State 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")
]
@State private var selectedRestaurant: Restaurant?

var body: some View {
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
Button(action: {
self.setCheckIn(item: restaurant)
}) {
HStack {
Text("Check-in")
Image(systemName: "checkmark.seal.fill")
}
}

Button(action: {
self.delete(item: restaurant)
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}

Button(action: {
self.setFavorite(item: restaurant)
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
}
.onTapGesture {
// 只要设置 selectedRestaurant,就会触发 actionSheet
self.selectedRestaurant = restaurant
}
.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()
])
}
}
.onDelete { indexSet in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
}

private func delete(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
restaurants.remove(at: index)
}
}

private func setFavorite(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
restaurants[index].isFavorite.toggle()
}
}

private func setCheckIn(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
restaurants[index].isCheckIn.toggle()
}
}
}

#Preview {
ContentView()
}

struct Restaurant: Identifiable {
var id = UUID()
var name: String
var image: String
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: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)

if restaurant.isCheckIn {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(.red)
}

if restaurant.isFavorite {
Spacer()
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
}
}

小结

  1. 左滑删除:通过在 ForEach 的数据源上添加 onDelete 修饰器,并结合 @State 来实现自动刷新。
  2. Context Menu:添加 contextMenu 修饰器,配置需展示的多个按钮操作。
  3. Action Sheet:通过 actionSheet(isPresented:)actionSheet(item:) 触发操作面板, 支持 .default.destructive.cancel 三种按钮样式。

希望本章能帮你更好地理解 SwiftUI 列表在交互上的灵活性和方便性。在接下来的开发中, 你可以将这些技巧扩展到更多场景,打造更丰富的用户体验。祝编码愉快!