Rust in Everything
#SwiftUI#iOS#Swift

在 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 修饰器,并在其中配置要展示的菜单项即可。 我们想在长按某行时弹出两项操作: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 的显示与隐藏:

  1. 通过 isPresented 来绑定一个布尔值。
  2. 通过可选的 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")
	}
}

完整代码示例

下面给出一份包含 Swipe-to-Delete、Context Menu、Action 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 列表在交互上的灵活性和方便性。在接下来的开发中, 你可以将这些技巧扩展到更多场景,打造更丰富的用户体验。祝编码愉快!

评论区

登录后即可发表评论

加载评论中…

💬 关联讨论

发起讨论
加载中...