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

第 16 章
運用可觀察物件與 Combine

Failure is an option here. If things are not failing, you are not innovating enough.

– Elon Musk

在上一章中,我們介紹了評分畫面來供使用者對餐廳進行評分,而評分按鈕目前缺乏功能,所需的行為是當選擇評分時,評分視圖會自行關閉,並且所選評分顯示在細節視圖中,如圖 16.1 所示。在本章中,我們將深入探討這個功能的實作細節。

圖 16.1. 在細節視圖中顯示評分
圖 16.1. 在細節視圖中顯示評分

最重要的是, 我將會簡要介紹一下 Combine, 它是與 SwiftUI 一起導入的框架。Combine 可以讓你輕鬆監看單一物件並取得其變更通知。當與 SwiftUI 結合時,我們無須編寫任何的程式碼,便可觸發視圖的更新,SwiftUI 與Combine 無縫地協同工,處理幕後的一切。

目前設計的問題

現在我們來看如何處理評分的選擇。如果你開啟 Restaurant.swift, 你會注意到 Restaurant 結構目前沒有用於儲存使用者評分的屬性,我們可以為 Restaurant 結構新增一個名為「rating」的新屬性,如下所示:

var rating: Rating?

rating 變數被定義為可選型別,這是因為使用者可能不會為餐廳評分。隨著這個變化, 我們還需要更新 init 方法如下:

init(name: String, type: String, location: String, phone: String, description: String, image: String, isFavorite: Bool = false, rating: Rating? = nil) {
    self.name = name
    self.type = type
    self.location = location
    self.phone = phone
    self.description = description
    self.image = image
    self.isFavorite = isFavorite
    self.rating = rating
}

現在我們回到 ReviewView.swift。要偵測使用者的選擇,我們可以將 .onTapGesture 修飾器加到 HStack 視圖,並將其放在 .animation 修飾器之後:

.onTapGesture {
    self.restaurant.rating = rating
    self.isDisplayed = false
}

糟糕 !Xcode 立即提示錯誤訊息給我們,如圖 16.2 所示。

圖 16.2. self 是不可變的錯誤
圖 16.2. self 是不可變的錯誤

錯誤的原因在於 restaurant 變數是不可變的,這表示我們不允許直接更新更新其值,那麼我們該如何更新restaurant 的 rating 屬性呢?

我們應該用 @State 標註 restaurant 變數?雖然這可以解訣錯誤,並讓我們更新評分,但是這個變更只在ReviewView 中可見,我們如何將評分的變化通知細節視圖,以便它可以顯示使用者選擇的評分呢?

使用可觀察物件

Combine 框架有一個名為「ObservableObject」的協定。透過採用這個協定,每其屬性值發生變化時,物件本身就可以通知其他視圖。

要使用 ObservableObject,我們需要對 Restaurant 結構進行一些變更。我們必須宣告 Restaurant 為類別而不是結構,才能採用 ObservableObject。

現在開啟 Restaurant.swift,並將內容替換如下:

import Combine

class Restaurant: ObservableObject {

    enum Rating: String, CaseIterable {
        case awesome
        case good
        case okay
        case bad
        case terrible

        var image: String {
            switch self {
            case .awesome: return "love"
            case .good: return "cool"
            case .okay: return "happy"
            case .bad: return "sad"
            case .terrible: return "angry"
            }
        }

    }

    @Published var name: String
    @Published var type: String
    @Published var location: String
    @Published var phone: String
    @Published var description: String
    @Published var image: String
    @Published var isFavorite: Bool = false
    @Published var rating: Rating?

    init(name: String, type: String, location: String, phone: String, description: String, image: String, isFavorite: Bool = false, rating: Rating? = nil) {
        self.name = name
        self.type = type
        self.location = location
        self.phone = phone
        self.description = description
        self.image = image
        self.isFavorite = isFavorite
        self.rating = rating
    }
}

如果你將修改後的程式碼和原來的 Restaurant 結構做比較,它們看起來非常相似, Rating 列舉與 init 方法保持不變,唯一的差異是我們宣告 Restaurant 為一個類別,並採用 ObservableObject 協定。此外,所有的屬性都標註 @Published。

@Published 是一個和 ObservableObject 一起使用的屬性包裹器, 當屬性標記為 @Publisher 時,它表示發布者(在本例中為 Restaurant 類別)應該在該屬性值發生變更時通知所有訂閱者(即視圖)。

Restaurant 類別與原來的結構非常一致,因此你無須對其他程式碼進行任何的變更。最重要的是,Xcode 不再對ReviewView 中加入的程式碼提出任何異議了:

.onTapGesture {
    self.restaurant.rating = rating
    self.isDisplayed = false
}

點擊「Play」按鈕,並在模擬器上測試 App,你應該能執行它,而不會出現任何錯誤。

在細節視圖中顯示評分

現在切換到 RestaurantDetailView.swift,我們必須更新細節視圖,以顯示所選的評分。找到顯示餐廳名稱與類型的程式碼:

VStack(alignment: .leading, spacing: 5) {
    Text(restaurant.name)
        .font(.custom("Nunito-Regular", size: 35, relativeTo: .largeTitle))
        .bold()
    Text(restaurant.type)
        .font(.system(.headline, design: .rounded))
        .padding(.all, 5)
        .background(.black)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .bottomLeading)
.foregroundStyle(.white)
.padding()

要顯示評分圖示,則用 HStack 視圖包裹 VStack,然後我們使用 Image 視圖來渲染評分。完整的程式碼如下所示:

HStack(alignment: .bottom) {
    VStack(alignment: .leading, spacing: 5) {
        Text(restaurant.name)
            .font(.custom("Nunito-Regular", size: 35, relativeTo: .largeTitle))
            .bold()
        Text(restaurant.type)
            .font(.system(.headline, design: .rounded))
            .padding(.all, 5)
            .background(.black)
    }
    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .bottomLeading)
    .foregroundColor(.white)
    .padding()

    if let rating = restaurant.rating, !showReview {
        Image(rating.image)
            .resizable()
            .frame(width: 60, height: 60)
            .padding([.bottom, .trailing])
            .transition(.scale)
    }
}
.animation(.spring(response: 0.2, dampingFraction: 0.3, blendDuration: 0.3), value: restaurant.rating)

在顯示圖片視圖之前,我們使用 if let 來驗證 restaurant.rating 是否有值。如果沒有評分, 我們將不會顯示圖片視圖。要修飾評分圖片的外觀,我們加入了 animation 修飾器並應用彈簧動畫。

這就是 ObservableObject 的強大之處。回想一下,Restaurant 的rating 屬性是用 @Published 標註,每當有任何值發生變化時,該物件都會通知所有相關的視圖。

在模擬器或預覽窗格上執行 App,當你在評分畫面中選擇評分後,細節視圖就會以漂亮的動畫來顯示相應的圖片,如圖 16.3 所示。

圖 16.3. 顯示評分圖示
圖 16.3. 顯示評分圖示

本章小結

在本章中,我簡要介紹了 Combine 框架。在 ObservableObject 的幫助下,我們可以輕鬆監看物件值的變化。然而,Combine 框架還有更多值得探討的內容,如果你想要深入了解 Combine 框架的話,我建議你觀看下列的影片:

在本章所準備的範例檔中,有最後完整的Xcode 專案可供你下載參考: http://www.appcoda.com/resources/swift59/swiftui-foodpin-observableobject.zip