在第 22 天,用了陣列儲存需要想式的資料物件,或稱它 "data-driven navigation" ,我們到昨天的範例中仍然使用陣列來儲存,那今天就來替換成 NavigationPath 。
NavigationPath 可以讓我們不需要透過任何資料型別之間的配合貨抽象化,只要是該型別有遵守 Hashable 即可推入,是個還滿方便的工具。
繼第 23 天結束
程式碼請到文章尾端「第 23 天為止的程式碼」段落查看
struct Dessert: Identifiable, Hashable {
let id = UUID().uuidString
let name: String
let amount: UInt
}
struct ContentView: View {
/* 省略 */
private let desserts = [
Dessert(name: "草莓蛋糕", amount: 10),
Dessert(name: "甜甜圈", amount: 23),
Dessert(name: "焙茶拿鐵", amount: 12),
Dessert(name: "金箔蛋糕", amount: 13)
]
@State private var path = [Flavor]()
var body: some View {
/* 省略 */
}
}
struct ContentView: View {
/* 省略 */
private let desserts = [ /* 省略 */ ]
@State private var path = NavigationPath()
var body: some View {
/* 省略 */
}
}
這時候「好手氣」按鈕的 action 會出錯,因為 NavigationPath 並沒有 append(contentsOf:) 這個方法,把 append 拆開即可:
Button {
if let flavor1 = flavors.randomElement(),
let flavor2 = flavors.randomElement() {
// 這邊會有錯誤:
// path.append(contentsOf: [flavor1, flavor2])
// 換成這樣即可
path.append(flavor1)
path.append(flavor2)
}
} label: {
Text("好手氣")
}
VStack {
ForEach(flavors) { flavor in
NavigationLink(flavor.name, value: flavor).padding()
}
ForEach(desserts) { dessert in
NavigationLink(dessert.name, value: dessert).padding()
}
}
.navigationDestination(for: Flavor.self) { flavor in
Text(flavor.name)
.navigationTitle(flavor.name)
}
.navigationDestination(for: Dessert.self) { dessert in
DessertView(dessert: dessert)
}
.navigationTitle("點心")
struct DessertView: View {
@State var dessert: Dessert
var body: some View {
VStack {
Text(dessert.name)
.font(.largeTitle)
.padding()
VStack {
Text("剩餘數量")
Text("\(dessert.amount)")
.padding()
.font(.system(size: 40, weight: .bold))
.background(
RoundedRectangle(cornerRadius: 10)
.foregroundColor(.teal)
)
}
}
.navigationTitle(dessert.name)
}
}
接下來改寫「好手氣」按鈕 random 的邏輯,讓 NavigationPath 實際發揮作用!
原本是這樣,只有 append flavor ,讓我們來試著也加入 Dessert 吧!
Button {
if let flavor1 = flavors.randomElement(),
let flavor2 = flavors.randomElement() {
path.append(flavor1)
path.append(flavor2)
}
} label: {
Text("好手氣")
}
.foregroundColor(.purple)
.padding()
其中的 random 邏輯改成這樣:
let pool: [any Hashable] = flavors + desserts
if let element = pool.randomElement() {
path.append(element)
}
由於兩者都是繼承 Hashable ,因此可以讓 pool 的型別為 [Hashable]
但是由於 Hashable 是 protocol ,所以需要寫成 [any Hashable]
random 結果看起來有點糟 XD
很大部份竟然都落在 dessert
既然都到這邊了,就再加個 deep link 唄
來訂這樣的需求
當收到這樣 Deep Link 的時候,顯示相對應的畫面
ironman2023://desserts/{:index}
若是其他網址,單純開啟 app 不需要做任何跳轉
例如,收到
ironman2023://desserts/1
就應該要顯示位於位置陣列位置 [1] 的「甜甜圈」的頁面
由於 deep link 處理變多了,因此把他先封裝到一個方法裡
在實務中,這適合抽出成另外一個專們的 class 來處理,避免在 view 裡面有太多和畫面不直接相關的程式碼。
.onOpenURL(perform: handleDeepLink(url:))
private func handleDeepLink(url: URL) {
guard let host = url.host() else { return }
switch host {
case "flavors":
let pathComponents = url.pathComponents.filter { $0 != "/" }
guard let first = pathComponents.first,
let index = Int(first),
index < flavors.count else { break }
let flavor = flavors[index]
path.append(flavor)
default: break
}
}
加入和 flavors 類似的邏輯,這邊可以看到程式碼重複很多,實務上這當然也需要再次重構,這邊就不處理了。
private func handleDeepLink(url: URL) {
guard let host = url.host() else { return }
let pathComponents = url.pathComponents.filter { $0 != "/" }
switch host {
case "flavors":
guard let first = pathComponents.first,
let index = Int(first),
index < flavors.count else { break }
let flavor = flavors[index]
path.append(flavor)
case "desserts":
guard let first = pathComponents.first,
let index = Int(first),
index < desserts.count else { break }
let dessert = desserts[index]
path.append(dessert)
default: break
}
}
從第 20 天開始,從透過 NavigationLink 可以從畫面觸發導向 (navigation)
從第 21 天開始,分享了 data driven 的導向方式,並概略性的分享如何以 deep link 為範例利用這個導向方式:
雖然實務上還需要做許多處理,例如有些畫面不一定是會透過 NavigationStack 來導向,可能會使用 sheet 或是其他呈現方式。不過相信這一系列的分享會是一個起始點。
以上!
今天的 SwiftUI 的大大小小就到這裡,明天見!
struct Flavor: Identifiable, Hashable {
let id = UUID().uuidString
let name: String
}
struct ContentView: View {
private let flavors = ["綠豆", "紅豆", "芋粿", "愛玉"].map(Flavor.init)
@State private var path = [Flavor]()
var body: some View {
NavigationStack(path: $path) {
Button {
if let flavor1 = flavors.randomElement(),
let flavor2 = flavors.randomElement() {
path.append(contentsOf: [flavor1, flavor2])
}
} label: {
Text("好手氣")
}
.foregroundColor(.purple)
.padding()
ForEach(flavors) { flavor in
NavigationLink(flavor.name, value: flavor).padding()
}
.navigationDestination(for: Flavor.self) { flavor in
Text(flavor.name)
.navigationTitle(flavor.name)
}
.navigationTitle("點心")
}
.onOpenURL { url in
guard let host = url.host() else { return }
switch host {
case "flavors":
let pathComponents = url.pathComponents.filter { $0 != "/" }
guard let first = pathComponents.first,
let index = Int(first),
index < flavors.count else { break }
let flavor = flavors[index]
path.append(flavor)
default: break
}
}
}
}
struct Flavor: Identifiable, Hashable {
let id = UUID().uuidString
let name: String
}
struct Dessert: Identifiable, Hashable {
let id = UUID().uuidString
let name: String
let amount: UInt
}
struct ContentView: View {
// MARK: Data Arrays
private let flavors = ["綠豆", "紅豆", "芋粿", "愛玉"].map(Flavor.init)
private let desserts = [
Dessert(name: "草莓蛋糕", amount: 10),
Dessert(name: "甜甜圈", amount: 23),
Dessert(name: "焙茶拿鐵", amount: 12),
Dessert(name: "金箔蛋糕", amount: 13)
]
// MARK: Navigation
@State private var path = NavigationPath()
// MARK: View
var body: some View {
NavigationStack(path: $path) {
Button {
let pool: [any Hashable] = flavors + desserts
if let element = pool.randomElement() {
path.append(element)
}
} label: {
Text("好手氣")
}
.foregroundColor(.purple)
.padding()
VStack {
ForEach(flavors) { flavor in
NavigationLink(flavor.name, value: flavor).padding()
}
ForEach(desserts) { dessert in
NavigationLink(dessert.name, value: dessert).padding()
}
}
.navigationDestination(for: Flavor.self) { flavor in
Text(flavor.name)
.navigationTitle(flavor.name)
}
.navigationDestination(for: Dessert.self) { dessert in
DessertView(dessert: dessert)
}
.navigationTitle("點心")
}
.onOpenURL(perform: handleDeepLink(url:))
}
private func handleDeepLink(url: URL) {
guard let host = url.host() else { return }
let pathComponents = url.pathComponents.filter { $0 != "/" }
switch host {
case "flavors":
guard let first = pathComponents.first,
let index = Int(first),
index < flavors.count else { break }
let flavor = flavors[index]
path.append(flavor)
case "desserts":
guard let first = pathComponents.first,
let index = Int(first),
index < desserts.count else { break }
let dessert = desserts[index]
path.append(dessert)
default: break
}
}
}
struct DessertView: View {
@State var dessert: Dessert
var body: some View {
VStack {
Text(dessert.name)
.font(.largeTitle)
.padding()
VStack {
Text("剩餘數量")
Text("\(dessert.amount)")
.padding()
.font(.system(size: 40, weight: .bold))
.background(
RoundedRectangle(cornerRadius: 10)
.foregroundColor(.teal)
)
}
}
.navigationTitle(dessert.name)
}
}
本篇使用到的 UI 元件和 modifiers 基本上沒有受到版本更新影響。若要在 Xcode 14 等環境下使用也是沒問題的。