在第 13 章中,你學到了使用 Form 元件來佈局表單。不過,目前表單還沒有功能,不論你選擇哪個選項,清單視圖都不會反映使用者偏好而有任何改變,這也是我們將在本章中討論與實作的內容。我們將繼續開發設定畫面,並依照使用者的個人偏好更新餐廳清單, 使 App 的功能完善。
具體而言,我們將在後面的小節討論下列主題:
如果你還沒有完成第 13 章作業,我鼓勵你花點時間練習。不過,如果你等不及要閱讀本章內容,你可以至下列網址下載範例專案:https://www.appcoda.com/resources/swiftui5/SwiftUIForm.zip 。
我們目前使用一個陣列來儲存「Display Order」的三個選項,它雖然能夠正常運作,不過還有一個更好的方式可以改善程式碼。
列舉為一組相關的值定義一般型別,並使你在程式碼中以型別安全的方式使用這些值。
- Apple 的官方文件 (https://docs.swift.org/swift-book/LanguageGuide/Enumerations.html)
由於這組固定值是和「Display Order」有關,因此我們可以使用列舉( Enum
)來存放它們,每個情況(case )指定一個整數值,如下所示:
enum DisplayOrderType: Int, CaseIterable {
case alphabetical = 0
case favoriteFirst = 1
case checkInFirst = 2
init(type: Int) {
switch type {
case 0: self = .alphabetical
case 1: self = .favoriteFirst
case 2: self = .checkInFirst
default: self = .alphabetical
}
}
var text: String {
switch self {
case .alphabetical: return "Alphabetical"
case .favoriteFirst: return "Show Favorite First"
case .checkInFirst: return "Show Check-in First"
}
}
}
使用列舉的優點是我們可在程式碼中以型別安全的方式使用這些值。最重要的是, Swift 中的 Enum
本身就是一級型別,這表示你可以建立實例方法來提供與值相關的附加功能。稍後,我們將會加入一個處理篩選的功能。同時,我們建立一個名為 SettingStore. swift
的新 Swift 檔來儲存 Enum,如圖 14.1 所示,你可以在專案導覽處的 SwiftUIForm
資料夾點擊右鍵,並選取 「 New File... 」來建立這個檔案。
建立 SettingStore.swift
之後,將上列的程式碼片段插入檔案中。接下來,回到 Setting View.swift
,我們將更新程式碼來使用 DisplayOrder
列舉,而不是使用 displayOrders
陣列。
首先,從 SettingView
刪除下列這行程式碼:
private var displayOrders = [ "Alphabetical", "Show Favorite First", "Show Check-in First"]
接下來,更新 selectedOrder
的預設值為 DisplayOrderType.alphabetical
,如下所示:
@State private var selectedOrder = DisplayOrderType.alphabetical
這裡,我們預設顯示順序為依字母排列(alphabetical ),如果你與先前的值(即「0」) 進行比較,轉換為使用列舉後,程式碼更易於閱讀了。接下來,你還需要在「Sort Preference」區塊更改程式碼,具體而言,我們更新ForEach
迴圈中的程式碼:
Section(header: Text("SORT PREFERENCE")) {
Picker(selection: $selectedOrder, label: Text("Display order")) {
ForEach(DisplayOrderType.allCases, id: \.self) {
orderType in
Text(orderType.text)
}
}
}
由於我們在 DisplayOrder
列舉中採用 CaseIterable
協定,因此我們可存取 allCases
屬性(該屬性包含所有列舉情況的陣列)來找出所有的顯示順序。
現在,你可以再次測試設定畫面,它應該可正常運作且外觀相同,不過底層程式碼更易於管理及閱讀了。
目前,App 還不能永久儲存使用者偏好。每當你重新啟動這個 App 時,設定畫面都會重置為預設設定。
有多種儲存設定的方式。要儲存少量資料(如 iOS 的使用者設定),內建的預設資料庫是方案之一。此預設系統讓App 以鍵值對的形式來儲存使用者偏好。要和這個預設資料庫互動,你可以使用一個名為 UserDefaults
的可程式化介面(programmatic interface )。
在 SettingStore.swift
檔中,我們將會建立一個 SettingStore
類別,以提供一些方便的方法來儲存及載入使用者偏好,在 SettingStore.swift
中插入下列的程式碼片段:
final class SettingStore {
init() {
UserDefaults.standard.register(defaults: [
"view.preferences.showCheckInOnly" : false,
"view.preferences.displayOrder" : 0,
"view.preferences.maxPriceLevel" : 5
])
}
var showCheckInOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showCheckInOnly") {
didSet {
UserDefaults.standard.set(showCheckInOnly, forKey: "view.preferences.showCheckInOnly")
}
}
var displayOrder: DisplayOrderType = DisplayOrderType(type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")) {
didSet {
UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
}
}
var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
didSet {
UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
}
}
}
我來簡短解釋一下程式碼,在 init
方法中,我們使用一些預設值來初始化預設系統。如果資料庫中找不到使用者偏好,才會使用這些值。
如前所述,你可以使用 UserDefaults
,以鍵值對的形式儲存設定。在上列的程式碼中, 我們為此目的宣告了三個屬性,以特定的鍵,從預設系統中載入對應的值,在didSet中,我們使用 UserDefaults
的 set
方法,來將值儲存在使用者預設。
settingStore
準備好後,我們切換到 SettingView.swift
檔來實作「Save」操作。首先, 在 SettingView
中為 SettingStore
宣告一個屬性。
var settingStore: SettingStore
而「儲存」(Save )按鈕的程式更新如下:
至於 Save 按鈕,你可以將 Save 按鈕的程式碼(在 ToolbarItem(placement: .navigationBarTrailing)
塊中)並將之改為:
Button {
self.settingStore.showCheckInOnly = self.showCheckInOnly
self.settingStore.displayOrder = self.selectedOrder
self.settingStore.maxPriceLevel = self.maxPriceLevel
dismiss()
} label: {
Text("Save")
.foregroundStyle(.primary)
}
我們插入三行程式碼來儲存使用者偏好。要在帶入設定視圖時載入偏好,你可以加入一個 onAppear
修飾器至NavigationView
,如下列所示:
.onAppear {
self.selectedOrder = self.settingStore.displayOrder
self.showCheckInOnly = self.settingStore.showCheckInOnly
self.maxPriceLevel = self.settingStore.maxPriceLevel
}
當視圖出現時,onAppear
修飾器會被呼叫,因此我們在它的閉包中從預設系統載入使用者設定。
在測試其變化之前,你必須更新 #Preview
,如下所示:
#Preview {
SettingView(settingStore: SettingStore())
}
現在,切換至 ContentView.swift
,並宣告 settingStore
屬性:
var settingStore: SettingStore
並更新 sheet
修飾器如下:
.sheet(isPresented: $showSettings) {
SettingView(settingStore: self.settingStore)
}
最後,更新 #Preview
如下:
#Preview {
ContentView(settingStore: SettingStore())
}
我們只是初始化一個 SettingStore
,並將其傳送給 SettingView
。這是必需的,因為我們已經在 SettingView
中加入 settingStore
屬性。
如果你現在編譯並執行該 App,Xcode 將顯示一個錯誤。在 App 可正常運作之前,我們還需要做一個修改。
至 SwiftUIFormApp.swift
並加入下列屬性來建立一個 SettingStore
實例:
var settingStore = SettingStore()
接著,將這行程式碼更改為下列程式碼,來修正錯誤:
ContentView(settingStore: settingStore)
現在,你應該能夠執行 App,並進行設定了。當你儲存設定之後,它將永久儲存在本地預設系統中。你可嘗試停止 App,然後再次啟動它,儲存的設定應已載入至設定畫面中,如圖 14.3 所示。
現在,使用者偏好已經儲存在本地預設系統中,但是清單視圖並沒有依照使用者設定來變更。同樣的,有多種方式可以解決這個問題。
我們概括說明一下目前的情形,當使用者在設定畫面中點擊「Save」按鈕,我們儲存所選的選項至本地預設系統中,然後關閉設定畫面,App 將帶使用者回到清單視圖。因此, 我們指示清單視圖來重新載入設定,或者清單視圖能夠監控預設系統的變更,並觸發清單的更新。
隨著 SwiftUI 的推出,Apple 還發布了一個名為「Combine」的新框架,根據Apple 的說法,這個框架提供一個宣告式 API 來隨著時間推移處理值。在此範例的內容中,Combine 讓你輕鬆監控單一物件,並取得變更通知。與SwiftUI 一起使用時,我們甚至可不撰寫一行程式碼,就觸發視圖更新,一切都由 SwiftUI 與 Combine 在幕後處理。
那麼,清單視圖如何知道使用者偏好已被修改,並觸發更新呢?
我來介紹三個關鍵字:
ObservableObject
一起使用的屬性包裹器。當一個屬性以 @Publisher
為前綴時,這表示發布者應該在值發生更改時通知所有訂閱者。我知道這有點令人困惑。不過,當我們看完程式碼後,你將會更加了解。
我們從 SettingStore.swift
開始。設定視圖與清單視圖需要監控使用者偏好的變化,因此 SettingStore
應該實作 ObservableObject
協定,並宣布 defaults
屬性的變更。在 Setting Store.swift
檔的開始處,我們必須先匯入 Combine 框架:
import Combine
SettingStore
類別應該採用 ObservableObject
協定。更新類別宣告,如下所示:
final class SettingStore: ObservableObject {
接下來,如下,在所有的屬性的前面插入 @Published
標註:
@Published var showCheckInOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showCheckInOnly") {
didSet {
UserDefaults.standard.set(showCheckInOnly, forKey: "view.preferences.showCheckInOnly")
}
}
@Published var displayOrder: DisplayOrderType = DisplayOrderType(type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")) {
didSet {
UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
}
}
@Published var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
didSet {
UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
}
}
藉由使用 @Published
屬性包裹器,發布者將在屬性的值發生變化時通知訂閱者(例如:displayOrder
的更新)。
如你所見,使用 Combine 通知變更的值非常容易。實際上,我們還沒有編寫任何新程式碼,只有採用所需的協定,並插入一個標記。
現在,我們切換至 SettingView.swift
。settingStore
現在應該宣告為環境物件,以讓我們可以與其他視圖共享資料。更新 settingStore
變數,如下所示:
@EnvironmentObject var settingStore: SettingStore
你不需要更新和「Save」按鈕有關的程式碼。不過,當你設定一個新值至設定儲存區時(例如:更新showCheckInOnly
,從 true 改為 false ),此更新將會發布,並讓所有訂閱者知道。
由於此變更,我們需要更新 #Preview
為下列內容:
#Preview {
SettingView().environmentObject(SettingStore())
}
這裡,我們將 SettingStore
的實例注入至環境中,以進行預覽。
好的,發布方已經完成,那麼訂閱者呢?我們要如何監控 defaults
的變化,並相應更新UI 呢?
在這個範例專案中,清單視圖是訂閱方,它需要監控設定儲存區的變化,並重新渲染清單視圖,以反映使用者的設定。現在開啟 ContentView.swift
來做一些變更。和我們剛才所做的操作類似,settingStore
現在應該宣告為一個環境物件:
@EnvironmentObject var settingStore: SettingStore
由於這個變更,因此應要修改 sheet
修飾器中的程式碼,以獲取此環境物件:
.sheet(isPresented: $showSettings) {
SettingView().environmentObject(self.settingStore)
}
另外,為了測試的目的,預覽程式碼應要相應更新,以注入環境物件:
#Preview {
ContentView().environmentObject(SettingStore())
}
最後,開啟SwiftUIFormApp.swift
,並更新WindowGroup 內的程式碼,如下所示:
struct SwiftUIFormApp: App {
var settingStore = SettingStore()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(settingStore)
}
}
}
這裡,我們呼叫 environmentObject
方法,將設定儲存區注入至環境。現在,設定儲存區的實例可用於 App 內的所有視圖。換句話說,設定與清單視圖皆可自動存取它了。
現在,我們已經實作了一個可以讓所有視圖存取的通用設定儲存區。最棒的是,只要設定儲存區中有任何更改,它會自動通知監控更新的視圖。儘管你看不出任何的視覺差異,但是當你更新設定畫面的選項時,設定儲存區會將變更通知至清單視圖。
我們最終任務是實作篩選與排序選項,以只顯示和使用者偏好相配的餐廳。我們從實作下列兩個篩選選項來開始:
在 ContentView.swift
中,我們將建立一個名為 showShowItem
的新函數來處理篩選:
private func shouldShowItem(restaurant: Restaurant) -> Bool {
return (!self.settingStore.showCheckInOnly || restaurant.isCheckIn) && (restaurant.priceLevel <= self.settingStore.maxPriceLevel)
}
該函數帶入一個餐廳物件,並告訴呼叫者是否應該顯示餐廳。在上列的程式碼中,我們檢查「Show Check-in Only」選項是否被選取,並檢驗指定餐廳的價位級別。
接下來,使用 if
語句包裹 BasicImageRow
,如下所示:
if self.shouldShowItem(restaurant: restaurant) {
BasicImageRow(restaurant: restaurant)
.contextMenu {
...
}
}
這裡,我們首先呼叫剛才實作的 shouldShowItem
函數,來檢查是否應該顯示餐廳。
現在按 Play 在模擬器執行 App 並快速測試。在設定畫面中,設定「Show Check-in Only」選項為「ON」, 並配置價位級別選項,以顯示價位級別為 3(即$$$ )或以下的餐廳,如圖 14.4 所示。當你點擊「Save」按鈕後,清單視圖應會自動更新(使用動畫),並顯示篩選後的紀錄。
現在,我們已經完成篩選選項的實作,我們來繼續處理排序選項。在 Swift 中,你可以使用 sort(by:) 方法對一個序列中的元素排序。當使用此方法時,你需要提供一個述詞(predicate )給它,在第一個元素應排在第二個元素之前,該述詞會回傳 true
。
舉例而言,要將 restaurants
陣列依字母排序,則可以使用 sort(by:)
方法,如下所示:
restaurants.sorted(by: { $0.name < $1.name })
這裡,$0 是第一個元素,$1 是第二個元素。在這個例子中,名稱為「Upstate」的餐廳大於名稱為「Homei」的餐廳,因此「Homei」將依順序放在「Upstate」的前面。
反之,如果你想要以字母降冪來排序餐廳,你可以編寫程式碼如下:
restaurants.sorted(by: { $0.name > $1.name })
我們如何排序陣列來顯示「check-in」優先,或顯示「favorite」優先呢?我們可以使用相同的方法,但是提供不同的述詞,如下所示:
restaurants.sorted(by: { $0.isFavorite && !$1.isFavorite })
restaurants.sorted(by: { $0.isCheckIn && !$1.isCheckIn })
為了更加組織程式碼,我們可以將這些述詞放在 DisplayOrder
列舉中。至 SettingStore.swift 檔,於 DisplayOrderType 中加入一個新函數,如下所示:
func predicate() -> ((Restaurant, Restaurant) -> Bool) {
switch self {
case .alphabetical: return { $0.name < $1.name }
case .favoriteFirst: return { $0.isFavorite && !$1.isFavorite }
case .checkInFirst: return { $0.isCheckIn && !$1.isCheckIn }
}
}
此函數僅回傳對應顯示順序的述詞(即一個閉包)。現在,我們準備進行最後的變更。回到 ContentView.swift
,並將 ForEach
敘述從:
ForEach(restaurants) {
...
}
變更為:
ForEach(restaurants.sorted(by: self.settingStore.displayOrder.predicate())) {
...
}
如此,你可以測試 App,並變更排序偏好。當你更新排序選項時,這個清單視圖將得到通知,並相應地重新排序餐廳。
你知道 SwiftUI 與 Combine 可幫助我們撰寫出更好的程式碼嗎?在本章最後兩節中,我們並沒有撰寫很多的程式碼來實作篩選及排序選項。Combine 處理事件處理的繁重工作, 將它與 SwiftUI 搭配使用時,它的功能更加強大,並節省你開發實作來監控物件的狀態變化與觸發 UI 更新的時間。一切幾乎是自動的,並由這兩個新框架來負責。
在下一章中,我們將會繼續透過「建立註冊畫面」來探索 Combine 。你將進一步了解 Combine 如何幫助你寫出更簡潔與模組化的程式碼。
在本章所準備的範例檔中,有完整的專案可供下載: