在前面的章節中,你已經學會如何使用清單來顯示資料列。而在本章中,我們將會更深入一點,了解如何讓使用者和清單視圖進行互動,包括:
參考圖 16.1,我相信你應該非常熟悉「滑動刪除」與「動作表」,這兩個 UI 元素在 iOS 中已經存在多年,而內容選單則是在 iOS 13 新導入,儘管它看起來類似 3D Touch 的預覽(peek )與彈出(pop )。對於使用內容選單實作的任何視圖(例如:按鈕),每當使用者在視圖上做壓力觸控(force touch )時,iOS 會帶出一個彈出選單。對於開發者而言,配置選單中顯示的動作項目是你的責任。
雖然本章的重點是「與清單的互動」,但是我所介紹的技巧也可以應用於其他的UI 控制元件(例如:按鈕)。
我們開始建立這個專案,而我們將以之前建立的清單 App 為基礎,來建立一個互動式清單。你可以至
https://www.appcoda.com/resources/swiftui4/SwiftUIActionSheetStarter.zip 來下載專案,下載後開啟專案,並檢視預覽,它應該會顯示一個包含文字與圖片的簡單清單,如圖 16.2 所示。稍後,我們將在這個範例 App 中加入滑動刪除功能、一個動作表與一個內容選單。
如果你的眼睛夠敏銳的話,你可能會發現起始專案使用 ForEach
來實作該清單。為什麼我將它變更回 ForEach
,而不是傳送資料集合至 List
呢?主要原因是我將介紹的 onDelete
處理器只能使用 ForEach
。
假設你已經準備好起始專案了,我們開始實作「滑動刪除」(swipe to delete )功能,我已經簡要提過 onDelete
處理器。要對清單中的所有列啟用「滑動刪除」功能,你只需要將這個處理器加到所有列的資料即可。因此,更新 List
如下:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
在 onDelete
的閉包中,它將傳送一個 indexSet
,其儲存要刪除的列的索引,然後我們使用 indexSet
呼叫remove
方法,以刪除在 restaurants
陣列中的特定項目。
在「滑動刪除」功能可以運作之前,還一件事要做。每當使用者從清單中刪除一列時, UI 應該相應更新。正如前面章節所討論的,SwiftUI 有一個非常強大的功能來管理應用程式的狀態。在我們的程式碼中,當使用者選擇刪除一筆紀錄時,restaurants
陣列的值將更考,我們必須要求 SwiftUI 監控屬性,並在屬性值更改時更新UI。
為此,插入 @State
關鍵字至 restaurants
變數:
@State var restaurants = [ ... ]
當你更改後,你應該能夠在預覧試試這個删除功能。向左滑動任一列來顯示出「刪除」(Delete )按鈕,如圖 16.3 所示。點擊它,該列將從清單中刪除。順帶一提,你是否注意到刪除該列時的精美動畫呢?你不需要撰寫任何額外的程式碼,這個動畫是由 SwiftUI 自動產生。很酷,對吧?
如果你之前使用過 UIKit 編寫相同的功能,我相信你會對 SwiftUI 感到驚訝。只需要幾行程式碼與一個關鍵字,你便已實作了「滑動刪除」功能。
接下來,我們來討論內容選單(Context Menu)。如前所述,內容選單類似於 3D Touch 的預覽(peek )及彈出(pop ),有個明顯的差別在於,這個功能適用於所有執行 iOS 13 與之後版本的裝置,即使該裝置不支援 3D Touch 也可以。要帶出內容選單,人們需要使用長按的手勢,而若是裝置使用 3D Touch,則使用壓力觸控。
SwiftUI 使得內容選單的實作變得非常簡單,你只需要將contextMenu 容器加到視圖, 並設定它的選單項目即可。
在我們的範例 App 中,我們想在人們長按任何一列時觸發內容選單。而在選單中,它提供了「刪除」(Delete )與「最愛」(Favorite )等兩個動作按鈕,供使用者選擇。當選擇後,「刪除」(Delete )按鈕將從清單中刪除該列,「最愛」(Favorite )按鈕將以星號標記所選的列。
要在內容選單中顯示這兩個項目,我們可以將 contextMenu
加到清單中的每一列,如下所示:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
Button(action: {
// 刪除所選的餐廳
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
Button(action: {
// 將所選的餐廳標記為最愛
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
}
}
.onDelete { (indexSet) in
self.restaurants.remove(atOffsets: indexSet)
}
}
.listStyle(.plain)
現在,我們還沒有實作任何按鈕動作。不過,若你試用 App,則當你長按其中一列時, 這個 App 會帶出內容選單,如圖 16.4 所示。
現在,我們繼續實作刪除動作。與 onDelete
處理器不同的是,contextMenu
不會給我們所選餐廳的索引。要找出已選餐廳的索引,則需要做一些工作。在 ContentView
建立一個新函數:
private func delete(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants.remove(at: index)
}
}
這個 delete
函數接受一個 =restaurant 物件,並在 restaurants
陣列中搜尋它的索引。要找出索引,我們呼叫firstIndex
函數,並指定搜尋條件,這個函數會逐一執行陣列,並將給定的餐廳 id 與陣列中的 id 進行比對,如果有匹配的話,firstIndex
函式會回傳給定餐廳的索引。當我們有了索引後,我們就可以透過呼叫 remove(at:)
從 restaurants
陣列中刪除餐廳。
接下來,在「// 刪除所選的餐廳」下面插入下列的程式碼:
self.delete(item: restaurant)
當使用者選擇「刪除」(Delete )按鈕時,我們只呼叫delete 函式。現在,你已經可以測試App 了。在畫布中點選「播放」(Play )按鈕,來執行該App,長按其中一列來帶出內容選單,接著選擇「刪除」(Delete ),你應會看到所選的餐廳從清單中刪除了。
我們繼續「最愛」(Favorite )按鈕的實作。當這個按鈕被選中時, App 將在所選的餐廳中放置一顆星。要實作這個功能,我們必須先修改 Restaurant 結構,並加入一個名為「isFavorite」的新屬性,如下所示:
struct Restaurant: Identifiable {
var id = UUID()
var name: String
var image: String
var isFavorite: Bool = false
}
這個 isFavorite
屬性指示餐廳是否標記為最愛,預設是設定為 false
。
與「刪除」功能類似,我們將會在 ContentView
中建立一個單獨的函數,來設定最愛的餐廳。插入下列程式碼來建立新函數:
private func setFavorite(item restaurant: Restaurant) {
if let index = self.restaurants.firstIndex(where: { $0.id == restaurant.id }) {
self.restaurants[index].isFavorite.toggle()
}
}
這段程式碼與 delete
函式的程式碼很相似。我們首先找出給定餐廳的索引,當我們有了索引後,我們變更它的isFavorite
屬性值。這裡,我們呼叫 toggle
函數來切換這個值,例如:若 isFavorite
的原始值設定為false
,則在呼叫 toggle()
後將變為 true
。
接下來,我們必須處理列的 UI。當餐廳的 isFavorite 屬性設定為 true
時,列應該顯示一個星號。更新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)
}
}
}
}
在上列的程式碼中,我們只在 HStack
中加入一個程式碼片段。若給定餐廳的 isFavorite
屬性設定為 true
,我們加入一個留白與一個系統圖片至該列。
這就是我們如何實作「最愛」功能的方式。最後在「// 將所選的餐廳標記為最愛」下面插入下列這行程式碼來呼叫setFavorite 函數:
self.setFavorite(item: restaurant)
現在該進行測試了。在畫布中執行 App,長按其中一列(例如:Petite Oyster ),然後選擇「最愛」(Favorite ),你應該會看到該列末尾出現一個星號,如圖 16.5 所示。
以上為如何實作內容選單的方式。最後,我們來看如何在 SwiftUI 中建立一個動作表。我們將要建立的動作表提供了與內容選單一樣的相同選項,如果你忘記動作表的外觀,請再次參考圖 16.1。
SwiftUI 框架有一個 ActionSheet
視圖,可以讓你建立動作表。基本上,你可以建立動作列表如下:
ActionSheet(title: Text("What do you want to do"), message: nil, buttons: [.default(Text("Delete"))]
你使用標題及選項訊息初始化一個動作表,buttons
參數接受一個按鈕的陣列。在範例程式碼中,它提供標題為「Delete」的預設按鈕。
要啟用動作表,可將 actionSheet
修飾器加到按鈕或任何視圖上來觸發動作表。如果你研究 SwiftUI 的文件,則有兩個帶出動作表的方式。
你可以使用 isPresented
參數來控制動作表的外觀:
func actionSheet(isPresented: Binding<Bool>, content: () -> ActionSheet) -> some View
或者使用 Optional 綁定:
func actionSheet<T>(item: Binding<T?>, content: (T) -> ActionSheet) -> some View where T : Identifiable
我們將使用這兩個方法來顯示動作表,你將可了解何時使用哪個方法。
於第一種方法,我們需要一個布林變數來表示動作的狀態,以及一個 Restaurant
型別的變數來儲存所選的餐廳。因此,在 ContentView
中宣告這兩個變數:
@State private var showActionSheet = false
@State private var selectedRestaurant: Restaurant?
預設上,showActionSheet
變數設定為 false
,這表示動作表不顯示。當使用者選取一列時,我們會將這個變數切換為 true
。顧名思義,selectedRestaurant
變數是設計用來存放所選的餐廳。這兩個變數都有 @State
關鍵字,因為我們想要 SwiftUI 監控它們的變化,並相應更新UI。
接下來,加入 onTapGesture
與 actionSheet
修飾器至 List
視圖,如下所示:
List {
ForEach(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
.contextMenu {
...
}
.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)
}
}
加到每列的 onTapGesture
修飾器, 是用於偵測使用者的觸控。當點擊一列後, onTapGesture
中的程式碼區塊將會執行。這裡,我們切換 showActionSheet
變數,並設定 selectedRestaurant
。
之前, 我已經解釋過 actionSheet
修飾器的用法。在上列的程式碼中, 我們使用 showActionSheet
的綁定來傳送 isPresented
參數,當 showActionSheet
設定為true
時, 則會執行該程式碼區塊。我們建立一個具有「標記為最愛」(Mark as Favorite )、「刪除」(Delete )與「取消」(Cancel )等三個按鈕的ActionSheet
,而動作表有三種按鈕型別, 包括「預設」(default )、「破壞性」(destructive)與「取消」(cancel )。對於一般動作, 動作表通常使用預設按鈕型別;破壞性按鈕與預設按鈕非常相似,但是字型顏色設定為「紅色」,以表示為一些破壞性的動作(例如:刪除);「取消」按鈕是一個特別的型別, 用於解除動作表。
對於「標記為最愛」(Mark as Favorite )按鈕,我們將其建立為預設按鈕。在action 閉包中,我們呼叫 setFavorite
函式來加入星星。對於「刪除」(Delete )按鈕,則建立為破壞性按鈕,其與內容選單的「刪除」(Delete )按鈕類似,我們呼叫 delete
函式來刪除所選餐廳。
如果你已正確進行變更,則在清單視圖中點擊其中一列時應可帶出動作表。當選擇「刪除」(Delete)按鈕,將會刪除該列。若是你選擇「標記為最愛」(Mark as Favorite)選項, 則將使用黃色星星標記該列,如圖16.6 所示
一切都運作得很好,不過我承諾過帶你了解使用 actionSheet
修飾器的第二種方法。我們已經介紹過第一種方法,它依據布林值(即 showActionSheet
)來指示是否應顯示動作表。
第二種方法是透過一個 Optional Identifiable 綁定來觸發動作表:
func actionSheet<T>(item: Binding<T?>, content: (T) -> ActionSheet) -> some View where T : Identifiable
以白話來說,這表示當你傳送的項目有值時,將會顯示動作。對於我們的範例, selectedRestaurant
變數是一個 Optional,並遵循 Identifiable
協定。要使用第二種方法,你只需要將 selectedRestaurant
綁定傳送至actionSheet
修飾器,如下所示:
.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()
])
}
如果 selectedRestaurant
有一個值,App 將會帶出動作表。從閉包的參數中,你可以取得所選的餐廳,並執行對應的操作。當你使用這個方法,則不再需要 shownActionSheet
布林變數,你可以從程式碼刪除它:
@State private var showActionSheet = false
另外,在 tapGesture
修飾器中,移除下列這行切換 showActionSheet
變數的程式碼:
self.showActionSheet.toggle()
再次測試 App,動作表看起來還是一樣,但你是以不同的方法來實作動作表。
現在你應該對如何建立一個內容選單有了概念,我們來做一個作業,以測試你對內容的了解程度。你的任務是在內容選單中加入「打卡」( Check-in )項目。當使用者選擇該選項時,App 將會在所選餐廳加入一個打卡符號,你可以參考圖 16.7 的UI 範例。對於這個範例,我使用了名為 checkmark.seal.fill
的系統圖片作為打卡符號,但你可以使用自己的圖片。
在參考解答之前,請花點時間來練習這個作業,祝你玩得愉快 !
在本章所準備的範例檔中,有完整的專案與作業解答可以下載: