iOS 17 App 程式設計實戰心法(SwiftUI)

第 8 章
顯示確認對話方塊及處理清單視圖選取

There is no learning without trying lots of ideas and failing lots of times.

- Jonathan Ive

你能夠完成前面的作業並建立重新設計的列佈局呢?如果你無法完成的話,不用擔心,我將在本章中引導你解決問題,並介紹一些新的佈局技術。至目前為止,我們只專注在清單視圖中顯示資料,但你可能想知道我們如何與清單視圖互動,並偵測列的選取,這正是我們將在本章中討論的內容。

首先,下載我們在上一章中建立的完整專案(http://www.appcoda.com/resources/swift59/swiftui-foodpin-custom-list.zip)我們將繼續改進這個 App,使其變得更好。簡而言之,我們準備要實作的內容如下:

  • 加入列佈局的替代設計。
  • 當使用者點擊清單視圖中的一個項目時,會彈出一個選單,選單提供兩種選項:「訂位」(Reserve a table )與「標記為最愛」(Mark as favorite )。
  • 當使用者選擇「標記為最愛」時,會顯示一個心形圖示。

透過實作這些新功能,你將學會如何改進 SwiftUI 程式碼,並使用動作選單(Actions Sheet )在 iOS 中顯示提示,如圖 8.1 所示。

圖 8.1. 捷徑與 Medium App 中的提示範例
圖 8.1. 捷徑與 Medium App 中的提示範例

建立更優美的列佈局

之前我曾給你做過一個作業,我要求你重新設計列佈局,使其看起來如圖 8.2 所示。我希望你已經試著找到解決方案,但即使你找不到設計該列的方式,一樣值得嘉獎,對於初學者來說,這個作業可能有些挑戰性。

圖 8.2. 重新設計列佈局
圖 8.2. 重新設計列佈局

現在我們來看看如何重新設計列佈局。假設你已經下載了專案,並在 Xcode 中開啟它, 選擇「RestaurantListView」來編輯程式碼,這裡不刪除 ForEach 中現有的 HStack,並為新佈局編寫程式碼,而是我們將 HStack 取出為子視圖,如此我們可輕鬆於新舊佈局之間做切換。

如前所述,Xcode 為開發者提供一個名為「Extract subviews」的便捷功能,可以輕鬆將某個區塊取出到子視圖中,如圖 8.3 所示。HStack 視圖是設計用來管理列佈局, 我們將它取出到子視圖中來改進我們的程式碼。按住 control 鍵並點選「HStack」, Xcode 取出該程式碼,並預設命名子視圖為「ExtractedView」,而我們將它重新命名為「BasicTextImageRow」,如圖 8.4 所示。

圖 8.3. 取出 HStack 到子視圖
圖 8.3. 取出 HStack 到子視圖

當你取出程式碼,Xcode 應該會提示一個錯誤,原因是新的 BasicTextImageRow 結構沒有 restaurantImages、restaurantNames、restaurantTypes 與 restaurantLocations 變數。

圖 8.4. 將子視圖重新命名為 BasicTextImageRow
圖 8.4. 將子視圖重新命名為 BasicTextImageRow

為了修正錯誤,我們在 BasicTextImageRow 結構中建立一些變數,並相應更新程式碼如下:

struct BasicTextImageRow: View {

    var imageName: String
    var name: String
    var type: String
    var location: String

    var body: some View {
        HStack(alignment: .top, spacing: 20) {
            Image(imageName)
                .resizable()
                .frame(width: 120, height: 118)
                .clipShape(RoundedRectangle(cornerRadius: 20))

            VStack(alignment: .leading) {
                Text(name)
                    .font(.system(.title2, design: .rounded))

                Text(type)
                    .font(.system(.body, design: .rounded))

                Text(location)
                    .font(.system(.subheadline, design: .rounded))
                    .foregroundStyle(.gray)
            }
        }
    }
}

BasicTextImageRow 結構現在接受四個變數,包含 imageName、name、type 與 location。隨著這個改動,你將需要更新 List 視圖,並為 BasicTextImageRow 傳送所需的值:

List {
    ForEach(restaurantNames.indices, id: \.self) { index in
        BasicTextImageRow(imageName: restaurantImages[index], name: restaurantNames[index], type: restaurantTypes[index], location: restaurantLocations[index])
    }

    .listRowSeparator(.hidden)
}
.listStyle(.plain)

如你所見,RestaurantListView 程式碼現在已經簡化了,雖然 UI 仍然相同,但是程式碼更易於閱讀及管理。

現在我們來為新的列佈局建立一個新結構,如圖 8.2 所示。在檔案中插入下列的程式碼:

struct FullImageRow: View {

    var imageName: String
    var name: String
    var type: String
    var location: String

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Image(imageName)
                .resizable()
                .frame(height: 200)
                .clipShape(RoundedRectangle(cornerRadius: 20))

            VStack(alignment: .leading) {
                Text(name)
                    .font(.system(.title2, design: .rounded))

                Text(type)
                    .font(.system(.body, design: .rounded))

                Text(location)
                    .font(.system(.subheadline, design: .rounded))
                    .foregroundStyle(.gray)
            }
            .padding(.horizontal)
            .padding(.bottom)
        }
    }
}

為了建立新的列佈局,我們使用兩個垂直堆疊,第一個 VStack 用於排列餐廳名稱、類型與位置,第二個 VStack 則包含了 Image 視圖與子 VStack。對於 Image 視圖,我們設定框(frame )的高度為「200 點」,由於我們省略了 width 參數,SwiftUI 會自動擴展圖片的寬度,以符合可用空間。

要使用新的 FullImageRow,則將 BasicTextImageRow 替換為 FullImageRow,如下所示:

FullImageRow(imageName: restaurantImages[index], name: restaurantNames[index], type: restaurantTypes[index], location: restaurantLocations[index])

當你更新程式碼後,List 視圖會使用新的列佈局,且預覽應該會顯示新的列佈局,如圖 8.5 所示。

圖 8.5. 使用新的 FullImageRow
圖 8.5. 使用新的 FullImageRow

如果你仔細看一下新佈局中的圖片,會發現它的縮放比例並不正確,幸運的是你已經學過 scaledToFit 修飾器,它可保持圖片的長寬比。那我們可使用這個修飾器來解決這個問題嗎?讓我們來試試看是否能解決問題。

更新 FullImageRow 中的 Image 視圖,並加上 scaledToFit 修飾器,如下所示:

Image(imageName)
    .resizable()
    .scaledToFit()
    .frame(height: 200)
    .clipShape(RoundedRectangle(cornerRadius: 20))

看一下預覽,是否已經解決了圖片視圖的縮放問題呢?顯然的,這個解決方案行不通,雖然我們可以保持圖片的長寬比,但是圖片會變小,如圖 8.6 所示。

圖 8.6. 使用scaledToFit 修飾器
圖 8.6. 使用scaledToFit 修飾器

要在保持圖片視圖寬度的同時,也保持圖片的長寬比,則你可以使用另一個名為「scaledToFill」的修飾器。透過將 scaledToFill 修飾器加到 Image 視圖,它將縮放圖片來填滿圖片視圖的可用空間,同時保持長寬比,這是你可以考慮的另一個選項,以實現所需的佈局,如圖 8.7 所示。

圖 8.7. 使用scaledToFill 修飾器
圖 8.7. 使用scaledToFill 修飾器

查閱文件

你可能好奇:「為什麼我熟悉這些修飾器及其用法呢?」

答案是「查閱文件」,你可以免費瀏覽 Apple 官方的《iOS 開發者參考文件》(https://developer.apple.com/documentation/ )。作為一個 iOS 開發者,習慣閱讀 API 文件至關重要,沒有一本書可以對 iOS SDK 做全盤介紹。當我們想要深入研究某個類別或協定時,我們經常查閱 API 文件來取得完整的資訊。

Apple 提供一個直接在 Xcode 中取得文件的便捷方法,你可以按住 option 鍵並點選修飾器名稱(或類別名稱)來開啟相關的文件,或者你可以使用鍵盤快速鍵 control-command-?,然後將游標懸停在修飾器上(例如:scaledToFill ),這會顯示一個包含修飾器介紹的彈出式視窗,讓你可以迅速取得相關文件如圖 8.8 所示。

圖 8.8. 取得修飾器的文件
圖 8.8. 取得修飾器的文件

如果你想進一步了解詳細資訊,則可以點選「Open in Developer Documentation」的連結,此連結會開啟文件瀏覽器,為你提供相關文件的全面觀點。

在繼續之前,我們將列佈局從 FullImageRow 更改為 BasicTextImageRow,我較喜歡基本佈局。

BasicTextImageRow(imageName: restaurantImages[index], name: restaurantNames[index], type: restaurantTypes[index], location: restaurantLocations[index])

使用狀態管理列的選取

我們接下來要實作的是,在使用者點擊清單視圖的一個項目時開啟選單,如圖 8.9 所示。但在我們深入實作之前,我來簡要介紹一個名為「@State」的屬性包裹器。

圖 8.9. 帶出可選選單
圖 8.9. 帶出可選選單

「狀態管理」(State Management)是每個開發者都會遇到的一個應用程式開發的重要方面。當使用者點擊一間餐廳或者一列時,建立適當的機制來追蹤其狀態(例如:是否已點擊)至關重要。

SwiftUI 提供幾個用於狀態管理的內建功能,其中包括 @State 的屬性包裹器,當你使用 @State 來標註屬性時, SwiftUI 會自動將其儲存在你的 App 中的某處。另外,使用該屬性的視圖會自動監聽該屬性值的任何變化,每當狀態發生變化時,SwiftUI 都會重新計算受影響的視圖,並相應更新 App 的外觀。

聽起來不錯,不是嗎?或者你對狀態管理有些困惑呢?

請放心,當我們深入研究程式碼範例時,你將對「狀態」與「綁定」有更清楚的了解。一開始於BasicTextImageRow 結構中插入下列這行程式碼:

@State private var showOptions = false

在程式碼片段中,我們透過使用 @State 標註來宣告狀態變數,它是一個初始值為「false」的布林變數,當點擊任何列項目時,我們會將其值從「false」更改為「true」。

偵測觸控並顯示確認對話方塊

那麼,我們如何偵測使用者何時點擊視圖呢?在 SwiftUI 中,你可以使用 onTapGesture 修飾器,它可讓你偵測使用者的觸碰。在 BasicTextImageRow 中,你可將此修飾器加到 HStack 中,如下所示:

HStack(alignment: .top, spacing: 20) {

  .
  .
  .

}
.onTapGesture {
    showOptions.toggle()
}

在 onTapGesture 的閉包中,我們切換 showOptions 的值,換句話說,當偵測到使用者的點擊時,我們將showOptions 的值從「false」更新為「true」,這示範了如何使用 @State 變數來變更列的狀態。

而下一步是什麼呢?我們現在可以偵測使用者的觸碰,讓我們探索如何顯示可選選單(Option Menu)呢?SwiftUI 提供一個名為「confirmationDialog」的修飾器來顯示選單(Selection Menu ),如圖 8.9 所示。這個修飾器在 iOS 15 中導入,作為 actionSheet 的替代,未來建議使用 confirmationDialog 而非 actionSheet 來顯示選單。

現在將 confirmationDialog 修飾器加到 HStack,如下所示:

HStack(alignment: .top, spacing: 20) {

  .
  .
  .

}
.onTapGesture {
    showOptions.toggle()
}
.confirmationDialog("What do you want to do?", isPresented: $showOptions, titleVisibility: .visible) {

    Button("Reserve a table") {

    }

    Button("Mark as favorite") {

    }
}

confirmationDialog 修飾器追蹤 showOptions 狀態變數,以確定對話方塊是否顯示給使用者看。換句話說,如果showOptions 的值設定為「false」,則確認對話方塊將保持隱藏狀態,只有當 showOptions 更新為 「true」 時,它才變得可見。

因此,當使用者點擊儲存格時,showOptions 狀態變數設定為「true」,從而觸發確認對話方塊的出現,我們也將確認對話方塊的標題設定為「What do you want to do?」, titleVisibility 參數則設為「.visible」,以確保標題可以始終顯示。

為了建立帶有三個按鈕的確認對話方塊,我們在閉包中定義了兩個動作,而「取消」按鈕是由確認對話方塊自動產生。

在模擬器中執行 App 或在預覽窗格中測試 App,你應該能透過點擊任何列來帶出動作選單,如圖 8.10 所示。

圖 8.10. 實作動作選單
圖 8.10. 實作動作選單

了解綁定

你對於跟 confirmationDialog 有關的程式碼有任何問題嗎?我猜你可能有一個疑問:你是否注意到我們傳送給confirmationDialog 的 showOptions 變數帶有$ 符號前綴?而 $ 符號是什麼呢?

我們開啟 confirmationDialog 的文件來深入了解細節。在「Declaration」區塊中,它指定 isPresented 參數接受與布林值的綁定,如圖 8.11 所示。

圖 8.11. confirmationDialog 的 API 文件
圖 8.11. confirmationDialog 的 API 文件

簡而言之,當你需要在 SwiftUI 中傳送綁定時,你必須在變數前面加上$ 符號。

現在我們來討論一下什麼是綁定。在 SwiftUI 中,「綁定」是儲存資料的屬性以及顯示和更改資料的視圖之間的雙向連接。要理解這個概念可能具有挑戰性,尤其你是 SwiftUI 的新手。

我們重新檢視一下剛才編寫的範例程式碼。showOptions 是控制動作選單可見性的屬性,當它設定為「true」時,動作選單將變得可見並顯示選單選項。

當使用者點擊「Cancel」按鈕或任何其他選單選項時,會發生什麼事呢?動作選單會自動隱藏自己,換句話說,它將 showOptions 的值從「true」更新為「false」。

在 SwiftUI 中,你不能只傳送 showOptions 的值,並期望 confirmationDialog 更新其值, 反而我們需要使用綁定。透過將 showOptions 的綁定傳送給 confirmationDialog,對話方塊可以更新 showOptions 的值。

顯示提示訊息

目前,無論你選擇哪個選項,該 App 都會關閉動作選單,而不執行任何動作,這是因為我們還沒有實作預設按鈕的後續動作。

對於「Reserve a table」按鈕,我們將顯示一個提示訊息來告知使用者該功能還無法使用。SwiftUI 有另一個名為「.alert」的修飾器,專門用來顯示提示訊息。

與動作選單相似,我們需要一個變數來控制提示的可見性,因此在 BasicTextImageRow 結構中宣告另一個名為「showError」的狀態變數:

@State private var showError = false

接下來,將 .alert 修飾器加到 HStack:

.alert("Not yet available", isPresented: $showError) {
    Button("OK") {}
} message: {
    Text("Sorry, this feature is not available yet. Please retry later.")
}

當 showError 設定為「true」時會觸發提示,因此更新「Reserve a table」按鈕的閉包如下:

Button("Reserve a table") {
    self.showError.toggle()
}

這就是我們在 SwiftUI 中顯示提示對話視窗的方式。執行 App 來快速測試一下,當你選擇「Reserve a table」選項時,你應該會看到提示訊息,如圖 8.12 所示。

圖 8.12. 顯示提示訊息
圖 8.12. 顯示提示訊息

實作「標記為最愛」功能

接下來是「標記為最愛」(Mark as favorite)功能的實作,當允許使用者將餐廳標記為最愛時,則需要將該狀態儲存在某處。我們需要找到替代方式來追蹤所選的項目,而建立另一個陣列來儲存選定的餐廳如何呢?在 RestaurantListView 結構中,我們宣告一個布林陣列:

@State var restaurantIsFavorites = Array(repeating: false, count: 21)

「布林」(Bool)是 Swift 中的一種資料型別,用來表示布林值。Swift 提供兩個布林值: 「true」與「false」。我們宣告 restaurantIsFavorites 陣列來存放布林值的集合,陣列中的每個值指示相應的餐廳是否被標記為最愛,例如:我們可以檢查 restaurantIsFavorites[0] 的值來查看 Cafe Deadend 是否被標記為最愛。

陣列中的值被初始化為 false,換句話說,預設上未選取這些項目。上列的程式碼展示了一種在 Swift 中使用重複值來初始化陣列的方式。這個初始化的方式如下:

@State var restaurantIsFavorites = [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]

我們必須使用 @State 標註變數的原因是「我們需要更新其值」。當使用者選擇「Mark as Favorite」選項時,我們將更新 restaurantIsFavorites 的值,並在餐廳名稱旁邊顯示一個心形圖示。

要實作這個功能,則在 BasicTextImageRow 中宣告另一個屬性:

@Binding var isFavorite: Bool

@Binding 關鍵字表示呼叫者必須負責提供狀態變數的綁定。如前所述,「綁定」在屬性及需要更改該屬性值的視圖之間的雙向連接,這裡我們將 RestaurantListView 中的 restaurantIsFavorites 屬性與 BasicTextImageRow 中的 isFavorite 變數連接起來。更新 BasicTextImageRow 視圖中的 isFavorite, 會將其值回傳 RestaurantListView 中的 restaurantIsFavorites 陣列的相應項目。

當使用者選擇該選項時,我們還沒有更新 isFavorite 的值。我們加入一行程式碼來切換該值:

Button("Mark as favorite") {
    self.isFavorite.toggle()
}

接下來,更新 HStack 的程式碼來加入心形圖片:

HStack(alignment: .top, spacing: 20) {
    Image(imageName)
        .resizable()
        .frame(width: 120, height: 118)
        .clipShape(RoundedRectangle(cornerRadius: 20))

    .
    .
    .

    if isFavorite {
        Spacer()

        Image(systemName: "heart.fill")
            .foregroundStyle(.yellow)
    }
}

我們檢查 isFavorite 的值是否設定為「true」。這裡我們將 Image 視圖加到 HStack 中, 我們使用來自 SF Symbols 的內建系統圖片,並將圖片的顏色設定為「黃色」,而 Spacer 是用來將心形圖片推到右邊緣。

BasicTextImageRow 現在已準備好為標記為最愛的餐廳加入心形圖示。最後一步是更新 RestaurantListView 中的下列程式碼:

BasicTextImageRow(imageName: restaurantImages[index], name: restaurantNames[index], type: restaurantTypes[index], location: restaurantLocations[index])

改為:

BasicTextImageRow(imageName: restaurantImages[index], name: restaurantNames[index], type: restaurantTypes[index], location: restaurantLocations[index], isFavorite: $restaurantIsFavorites[index])

我們加入了新參數 isFavorite,並傳送了對應陣列項目的綁定,如此我們透過在模擬器或預覽窗格中執行 App 來測試它,如圖 8.13 所示。

圖 8.13. 選擇標記為最愛時顯示心形圖示
圖 8.13. 選擇標記為最愛時顯示心形圖示

預覽列佈局

在結束本章之前,我要分享一個預覽列佈局的訣竅。現在我們已經實作了兩種列佈局,如 BasicTextImageRow 與FullImageRow,我們可以在 RestaurantListView 結構中輕鬆於兩者之間切換。

然而,如果我們想同時預覽這兩個列佈局時怎麼辦?我們該如何做呢?

SwiftUI 中的所有視圖都可以預覽,你可以另外增加 #Preview 程式碼區塊如下:

#Preview("BasicTextImageRow", traits: .sizeThatFitsLayout) {
    BasicTextImageRow(imageName: "cafedeadend", name: "Cafe Deadend", type: "Cafe", location: "Hong Kong", isFavorite: .constant(true))
}

#Preview("FullImageRow", traits: .sizeThatFitsLayout) {
    FullImageRow(imageName: "cafedeadend", name: "Cafe Deadend", type: "Cafe", location: "Hong Kong")
}

我們為 BasicTextImageRow 與 FullImageRow 視圖建立另外兩個預覽程式碼區塊。而 traits 參數對你來說是陌生的,它可以讓我們自訂預覽環境,,我們不想在全螢幕模擬器上預覽列佈局,而是想要在容器中渲染預覽,透過將sizeThatFitsLayout 值傳送給 traits 參數,我們可以實作如圖 8.14 所示的列佈局預覽。請注意,你需要切換為Selectable 模式, 才能預覽佈局。

圖 8.14. 預覽列佈局
圖 8.14. 預覽列佈局

.constant(true) 就是所謂的「常數綁定」。為了預覽目的,我們只向 BasicTextImageRow 傳送一個不會更改的寫死值。

你的作業:支援新功能與移除圖示

作業①:使不同列佈局支援「標記為最愛」的功能

在專案中,我們還建立了另一種名為「FullImageRow」的列佈局,你的任務是修改FullImageRow 的程式碼,使其也支援「標記為最愛」的功能。

Figure 8-15. Adding the heart icon to FullImageRow
Figure 8-15. Adding the heart icon to FullImageRow

作業②:移除心形圖示

目前,該 App 還沒有提供移除心形圖示的功能。請思考如何更改程式碼,來讓 App 可以切換心形圖示。如果所選的餐廳被標記了,你還需要為「Remove from favorites」按鈕顯示不同的標題。進行更改並不會太困難,請花一些時間來進行這個練習,我相信你會獲益良多。

圖 8.16. 從最愛中移除心形圖示
圖 8.16. 從最愛中移除心形圖示

本章小結

至此,你應該已經對「建立清單視圖」、「實作不同類型的列佈局」以及「處理列的選取」有了深入的了解。你現在準備好自己建立一個簡單的清單視圖 App,我總是鼓勵你建立自己的專案,不需要一開始就建立大專案。如果你喜愛旅遊,可建立一個簡單的 App 來顯示你最喜愛的旅遊地點清單;如果你喜愛音樂,也可以開發自己的 App 來顯示你最喜愛的專輯清單。只需使用 Xcode 試驗,在錯誤中逐步學習。

在本章所準備的範例檔中,有最後完整的 Xcode 專案 [http://www.appcoda.com/resources/swift59/swiftui-foodpin-list-selection.zip 與作業的解答 http://www.appcoda.com/resources/swift59/swiftui-foodpin-list-selection-exercise.zip

在下一章中,我們將繼續探索清單視圖,並了解如何從清單中刪除列。