• 8

Swift程式設計[第4單元] SwiftUI動畫與繪圖

雪白西丘斯 wrote:
#4 4-1c 雨燕(恕刪)



老師請教一下,這個func
func animation<v>
< >是不是generic?
表示它可以丟Double或是Int?


https://developer.apple.com/documentation/swiftui/view/animation(_:value:)</v>
回覆問題

CUNNING wrote:
老師請教一下,這個funcfunc...(恕刪)


官方文件正確網址是:https://developer.apple.com/documentation/swiftui/view/animation(_:value:)

文件宣告為:
func animation<V>(
_ animation: Animation?,
value: V
) -> some View where V : Equatable

<V>意思是參考了一個 "Generic Type" 一般化類型 V,最後還有限制 where V : Equatable,是說 V 必須符合 Equatable 規範,也就是資料類型必須能比較「是否相等」。

所以這個宣告的意思是說:

函式 animation 參數使用了一般化類型V,第一個參數名稱(也剛好叫animation)可省略,類型是Animation? (若給值是 nil 的話,則停止動畫效果),第二個參數名稱為 value,類型是任何能夠比較「是否相等」的資料類型,Int, Double 當然都是。

value 參數的用意,是如果 value 值有任何變化(要比較「是否相等」的目的),則啟動動畫效果。如果完全省略 value 參數,則任何狀態變數有變化都會觸發動畫效果。

函式最後會返回某種視圖類型。

根據上面語法寫一個簡單範例如下:
// Tested by Heman, 2022/07/05
// https://developer.apple.com/documentation/swiftui/view/animation(_:value:)
import PlaygroundSupport
import SwiftUI

struct 動畫測試: View {
@State var 透明度 = 1.0
@State var 水平位移 = 100.0
var body: some View {
Text("System Alert!")
.font(.title)
.foregroundColor(.red)
.opacity(透明度)
.offset(x: 水平位移, y: 0)
.animation(.easeIn.repeatForever(), value: 透明度)
.onAppear {
// 透明度 = 0.0
水平位移 = -100
}
}
}

PlaygroundPage.current.setLiveView(動畫測試())


animation() 的第一個參數為 Animation 物件實例,這裡用 Animation.easeIn 是一個 "type property",本身就是一個物件實例,然後接一個物件方法 repeatForever(),第二個參數「透明度」對應<V>,具體的類型是 Double (實數)。

這個範例程式並不會觸發動畫效果,若將 // 透明度 = 0.0 的註解(//)拿掉才會,猜猜看會觸發一種動畫(透明度)還是兩種(透明度+水平位移)?

上述宣告語法,在 Swift 5.7 又有改進,不必寫出<V>,而是可改為更容易閱讀的語法:
func animation(_ animation: Animation?, value: any Equatable) -> some View

關於 Swift 5.7 Generic Type, some, any 新語法,可參考:
https://swiftsenpai.com/swift/understanding-some-and-any/?utm_source=rss&utm_medium=rss&utm_campaign=understanding-some-and-any
雪白西丘斯 wrote:
回覆問題官方文件正確(恕刪)


謝謝老師,這樣我的理解沒錯了
現在比較看得懂它文件的寫法了,之前看不懂
所以很難理解為什麼老師可以寫出那一長串,裡面怎麼都知道要放什麼東西
雪白西丘斯

不錯,能看懂Apple原廠文件,表示有相當功力了。

2022-07-06 10:32
4-9f App解說

目前Swift Playgrounds 4.x 支援三種檔案格式,可根據副檔名來區別,三者比較如下表:
# 副檔名 檔案格式名稱 用途說明
1 .playground Xcode Playground 2014年隨Swift程式語言一起發布的功能,可在Xcode裡面快速測試一段Swift程式,是Swift Playgrounds 的前身。
2 .playgroundbook Swift Playground Book 2016年將Xcode Playground擴展成為一個獨立App,就是我們現在用的Swift Playgrounds,增加了「電子書」功能,可將Swift程式與電子書內容包裹在一起,邊看邊學程式設計。
3 .swiftpm Swift Package Manager 讓Swift程式模組化,方便導出或導入外部程式模組(稱為Package套件),以開發App軟體。2019年先整合到Xcode,2021年整合進Swift Playgrounds 4.0。



當使用Swift Playgrounds 4.x新增App (+ App)時,就會產生一個 .swiftpm 檔案包裹(可視為一個目錄),裡面可以包含多個 .swift 原始程式、圖片影音媒體、JSON資料檔案、外部(如GitHub) Swift 套件…等,組成一個App所需要的原始素材。

在App(.swiftpm)中,每個Swift程式的視圖(View)都可單獨預覽(preview),這樣就能邊寫邊測試,需要預覽的視圖,只要額外加上一小段程式碼,句型如下:
struct 某視圖預覽: PreviewProvider {
static var previews: some View {
某視圖()
}
}

預覽(preview)功能其實就是先單獨執行並顯示這個視圖,Swift Playgrounds 會搜尋目前編輯中的程式碼,只要符合 PreviewProvider 規範的物件(名稱可隨意自定),都會在右側欄的預覽畫面中自動執行並顯示結果。

PreviewProvider 規範需要一個 “static var previews” 變數,是個類型屬性(Type properity),也就是說,自動執行時會抓「某視圖預覽.previews」當做視圖主體顯示出來,「某視圖預覽」不必再加括號。需要預覽的「某視圖()」則可依照需求加入參數或視圖修飾語。

我們在第一段程式預覽的是「選擇照片()」視圖,這段程式主要測試「開啟相簿()」的功能,「開啟相簿()」就是在本課一開始(4-9a)提到的,未來SwiftUI可利用 PhotosPicker 來開啟相簿選擇照片,不過目前先用舊的 UIKit 來橋接,等年底新版Swift Playgrounds 發表後再來修改。
struct 選擇照片: View {
@State var 圖片集: [UIImage] = []
@State var 開關 = true
var body: some View {
ZStack {
Color.black
if 圖片集.isEmpty {
Text("請選擇一張照片")
.font(.title)
} else {
Image(uiImage: 圖片集.first!)
.resizable()
.scaledToFit()
}
}
.sheet(isPresented: $開關) {
開啟相簿(result: $圖片集, popup: $開關, limit: 1)
}
.onTapGesture {
開關.toggle()
}
}
}

.sheet() 視圖修飾語類似 ZStack 或 .overlay(),可以在視圖上疊加另一層視圖畫面,而且透過 isPresented 參數來控制顯示與否。

對相簿而言,選完照片之後,就應將 isPresented 設為 false,不再顯示。但是選擇照片的動作是在另一個視圖「開啟相簿()」中進行,因此 isPresented 必須用 $ 才能將改變後的參數值帶回來。

至於「開啟相簿()」的程式碼(請參考前一節4-9e),主要參考網路資源加以修改,是以 UIKit 語法撰寫,透過 UIViewControllerRepresentable 規範包裝成 SwiftUI 視圖,未來以 PhotosPicker 取代之後,就不再需要了,在此不多介紹。

第二段預覽視圖為「相簿圖片輪播()」,也就是將「開啟相簿()」選出的照片,在畫布(Canvas)中手動輪播,手動輪播的程式碼直接引用自4-9d,不再重複說明。主畫面用ZStack疊了3層:最底下(背景)用全黑,中間是畫布(照片輪播),最上層是3個功能按鈕(目前只用print()輸出訊息到主控台,未來再將實際功能加進去):
struct 相簿圖片輪播: View {
var body: some View {
ZStack(alignment: .trailing) {
Color.black
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date)
}
VStack {
Group {
Button(
action: {
print("Save to Photo Library")
},
label: {
Image(systemName: "square.and.arrow.down.fill")
})
Button(
action: {
print("Update photos from the web")
},
label: {
Image(systemName: "arrow.clockwise.icloud.fill")
})
Button(
action: {
print("Open Photo Library")
}, label: {
Image(systemName: "photo.on.rectangle.angled")
})
}
.font(.title)
.foregroundColor(.white)
.shadow(color: .blue, radius: 40, x: 0, y: 0)
.padding()
}
}
}
}

按鈕的語法有好幾種,但基本參數有兩個,一是 action,代表按了以後要執行的動作;二是 label (標籤),即按鈕的外觀,可以是文字或任意視圖,在此我們用系統圖示(SF Symbols)來當做按鈕外觀。標準句型如下,注意這兩個參數值都是匿名函式:
Button(
action: {...},
label: {...})

最後顯示結果如下圖,有3個地方可以手勢互動:圖片用拖曳手動輪播,輕點照片可重新顯示相簿,按鈕可按。


💡 註解
  1. 「開啟相簿()」程式主要參考以下問答所修改:Presenting PHPicker with SwiftUI
  2. UIKit 背後採用 MVC (Model-View-Controller) 架構,將物件區分為多種角色,有些提供資料模型(Model)、有些顯示外觀(View)、有些負責控制流程(Controller),還有代理者(Delegate)或協調者(Coordinator)等等,相當複雜,這是過去40年來發展至今,非常成熟的物件導向軟體架構。也因為基於這些經驗,才能化繁為簡,發展出SwiftUI 宣告式語法。
  3. 英文 sheet 是床單、紙張、一片玻璃等意思,大致指薄而平面的物品,或做為量詞(如 100 sheets of paper)。常用的辦公室軟體之一「試算表」就叫做 Spreadsheet (spread: 展開),Google 的免費線上版試算表直接叫做 Google Sheets。
雪白西丘斯 wrote:
4-9f App解說(恕刪)


有鐵槌的Playground躲在書籍裡
點右下角檢視全部->下拉到書籍->滑到最右邊



.playgroundbook好像不能用Xcode開啟
我現在都用有鐵槌的Playground搭配Markup Formatting Reference做筆記,還蠻方便的
<h1>字體</h1>

看起來只能用[ b ] [/ b]把括號處理掉
CUNNING wrote:
字體看起來只能用[ b(恕刪)

測試一下如何顯示角括號



(1) BBCode Bold
func animation<V>(
_ animation: Animation?,
value: V
) -> some View where V : Equatable


(2)HTML Bold
func animation<V>(
_ animation: Animation?,
value: V
) -> some View where V : Equatable


不錯,真的有用,終於可以在Mobile01顯示角括號了。
CUNNING

01在預覽時可以,一發文就被當做html了

2022-07-11 18:29
第10課 App-2 芝加哥藝術博物館v2

還記得在第3單元,我們透過 iTune 與芝加哥藝術博物館兩個提供Open API的網站,學習網路程式設計,其中 iTune 主要提供音樂資料庫(超過5千萬首歌曲),而芝加哥藝術博物館則有非常豐富的視覺藝術(30多萬件藏品)。

上一課所學的圖片輪播正適合展示藝術作品,所以本單元第2個App就來改寫3-4b的芝加哥藝術博物館,一方面將網路程式改成async/await非同步語法,另一方面操作模式也改用上一課的圖片輪播,以展示博物館精美畫作。

第一部分先修改3-4b的網路程式,這部份是透過 API 搜尋畫家名字取得作品列表,原來是比較舊的寫法:
guard let myURL = myURLComponent.url else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 解碼資料 = 回傳資料 {
do {
let 解碼結果 = try JSONDecoder().decode(搜尋結果.self, from: 解碼資料)
print(回傳碼 ?? "No response")
作品列表 = 解碼結果.data
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}

早期 URLSession.share.dataTask() 的用法,在上一單元3-1b詳細說明過。若改成新的 async/await 非同步語法,會變得更簡潔,可讀性更高,如下:
if let myURL = myURLComponent.url {
let (內容, 回傳碼) = try await URLSession.shared.data(from: myURL)
print(回傳碼)
let 解碼結果 = try JSONDecoder().decode(搜尋結果.self, from: 內容)
return 解碼結果.data
}

這段程式碼放在 async throws 函式「搜尋作品列表」裡面,語法雖然簡單,其中關鍵在於 async/await 以及 throws-try 的用法,背後對應的「非同步」與「錯誤處理」的觀念非常重要,有疑問的話,請複習第3單元7, 8兩課內容。
func 搜尋作品列表(_ artist: String) async throws -> [搜尋品項] {
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "api.artic.edu"
myURLComponent.path = "/api/v1/artworks/search"
myURLComponent.query = "q=\(artist)&limit=20"
if let myURL = myURLComponent.url {
let (內容, 回傳碼) = try await URLSession.shared.data(from: myURL)
print(回傳碼)
let 解碼結果 = try JSONDecoder().decode(搜尋結果.self, from: 內容)
return 解碼結果.data
}
return []
}

函式裡面有兩行用到 try 的地方,表示可能會有錯誤發生,第一個可能是網路抓不到資料,第二個可能是內容格式不合,導致傑森解碼器出錯。若遇到任何錯誤發生,則會拋出錯誤,立即從函式返回,不再往下執行。

第二部分改用「圖片輪播」,會在後面陸續完成。本節先引進一個SwiftUI物件:Picker 選擇器。

原先在3-4b我們設計一個「搜尋框」,讓使用者輸入藝術家名稱來搜尋作品,這樣雖然很有彈性,但對於不熟悉西洋藝術的使用者來說,可能不知道該搜尋什麼。


博物館藏品雖多,但其實還是有限的,未必涵蓋每個畫家,無限制的搜尋不見得都有效,不如將其藏品的「關鍵字」做一個列表,讓使用者選擇,每個選擇一定會有對應作品,這樣App體驗效果會更理想。

所以我們用Picker物件來做一個選單,裡面列出博物館收藏的畫家名字,讓使用者容易選擇。Picker 的基本用法如下:
Picker("芝加哥藝術博物館", selection: $搜尋字串) {
Text("畢卡索(Pablo Picasso)").tag("Pablo Picasso")
Text("莫內(Claude Monet)").tag("Claude Monet")
Text("梵谷(Vincent van Gogh)").tag("Vincent van Gogh")
Text("威廉透納(William Turner)").tag("William Turner")
Text("秀拉(Georges Seurat)").tag("Georges Seurat")
}

Picker() 小括號內兩個參數,一個是顯示選單的文字標籤「芝加哥藝術博物館」,另一個是要帶回的選擇內容,須加上 $ 號,才能將帶回值指定給「搜尋字串」。

後面尾隨的匿名函式,則是Picker選單的內容,以 Text() 文字視圖組成選單,此例Text()內容包含中英文名字,是給使用者看的,當選擇了某個選項之後,實際帶回來的值,則是由 .tag() 修飾語決定,在此僅帶回畫家的英文名字,例如”Vincent van Gogh”,以提供搜尋使用。

所以Picker選單中,給使用者看的內容與實際帶回的值,兩者可以一致,也可以完全不同。

Picker()之後接一個 .onChange() 修飾語,將帶回的「搜尋字串」用以呼叫「搜尋作品列表()」,就得到新的作品列表:
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品列表 = try await 搜尋作品列表(搜尋字串)
} catch {
print("網路有問題:\(error)")
}
}
}

最後將作品列表用 List 表列出來,下一節再逐一展示圖片,執行結果如下圖:


本節完整程式碼如下,先用Swift Playgrounds的電子書(.playgroundbook)模式,等最後測試好再移入App(.swiftpm)模式:
// 4-10a App-2: 芝加哥藝術博物館(https://api.artic.edu/docs/)
// Created (for 3-4a) by Heman, 2021/10/02
// Revised (for 4-10a) by Heman, 2022/07/16
import PlaygroundSupport
import SwiftUI

// Data model for JSONDecoder
struct 搜尋結果: Codable {
let pagination: 分頁資訊
let data: [搜尋品項]
}

struct 分頁資訊: Codable {
let total: Int
let limit: Int
let offset: Int
let total_pages: Int
let current_page: Int
}

struct 搜尋品項: Codable, Identifiable {
let api_link: URL?
let id: Int
let title: String
}

struct 展示作品: View {
@State var 搜尋字串 = "Vincent van Gogh"
@State var 作品列表: [搜尋品項] = []

func 搜尋作品列表(_ artist: String) async throws -> [搜尋品項]{
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "api.artic.edu"
myURLComponent.path = "/api/v1/artworks/search"
myURLComponent.query = "q=\(artist)&limit=20"
if let myURL = myURLComponent.url {
let (內容, 回傳碼) = try await URLSession.shared.data(from: myURL)
print(回傳碼)
let 解碼結果 = try JSONDecoder().decode(搜尋結果.self, from: 內容)
return 解碼結果.data
}
return []
}

var body: some View {
Picker("芝加哥藝術博物館", selection: $搜尋字串) {
Text("畢卡索(Pablo Picasso)").tag("Pablo Picasso")
Text("莫內(Claude Monet)").tag("Claude Monet")
Text("梵谷(Vincent van Gogh)").tag("Vincent van Gogh")
Text("威廉透納(William Turner)").tag("William Turner")
Text("秀拉(Georges Seurat)").tag("Georges Seurat")
}
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品列表 = try await 搜尋作品列表(搜尋字串)
} catch {
print("網路有問題:\(error)")
}
}
}
List(作品列表) { 作品 in
Label(作品.title, systemImage: "rectangle.portrait")
.font(.title2)
.lineLimit(1)
}
.task {
do {
作品列表 = try await 搜尋作品列表(搜尋字串)
} catch {
print("網路有問題:\(error)")
}
}
}
}

PlaygroundPage.current.setLiveView(展示作品())

當然,實際的畫家不只這5位,下一節我們會用 ForEach 改寫成完整的 Picker 選單。

💡 註解
  1. Swift Playgrounds 4.1 for macOS 在 App 模式(.swiftpm)下寫網路程式,必須手動開啟「網路連線」的功能權限,URLSession 物件才能正常使用,設定步驟參考下篇補充說明。iPad版本App模式不需要此步驟,預設就已開啟。
  2. 英文 pick 是動詞「挑選」「採摘」,所以 Picker 是挑選器的意思,通常作為多選一的選單。除此之外,SwiftUI 還有其他特定用途的挑選器,包括 DatePicker, MultiDatePicker, ColorPicker 等,還有 PhotoKit 提供的 PhotosPicker 用來挑選相簿照片。
補充:Swift Playgrounds 4.1 for macOS — 解決App網路無法連線問題

Swift Playgrounds 最初只能練習寫Swift程式,不能開發App,直到2021年12月發行的 4.0 for iPadOS 以及2022年5月的 4.1 for macOS 才首次可以開發App。最初測試時,發現 4.1 for macOS 的 App 模式無法寫網路程式,經過一番搜尋才知道,原來是 macOS 版本需要手動開啟網路權限(iPadOS預設已開啟)。

一開始在 Swift Playgrounds 4.1 for macOS 寫網路程式 App (如上節範例4-10a),會發生以下錯誤,URLSession 完全不能用,如下圖:

網路有問題:Error Domain=NSURLErrorDomain Code=-1003 "無法找到指定主機名稱的伺服器。" UserInfo={_kCFStreamErrorCodeKey=-72000, NSUnderlyingError=0x600000e09ad0 {Error Domain=kCFErrorDomainCFNetwork Code=-1003 "(null)" UserInfo={_NSURLErrorNWPathKey=satisfied (Path is satisfied), interface: en1, ipv4, dns, _kCFStreamErrorCodeKey=-72000, _kCFStreamErrorDomainKey=10}}, _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <ea431b3f-70a5-4f0e-b5ec-a031e211251a>.<1>, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalDataTask <ea431b3f-70a5-4f0e-b5ec-a031e211251a>.<1>"
), NSLocalizedDescription=無法找到指定主機名稱的伺服器。, NSErrorFailingURLStringKey=https://api.artic.edu/api/v1/artworks/search?q=Vincent%20van%20Gogh&limit=20, NSErrorFailingURLKey=https://api.artic.edu/api/v1/artworks/search?q=Vincent%20van%20Gogh&limit=20, _kCFStreamErrorDomainKey=10}</ea431b3f-70a5-4f0e-b5ec-a031e211251a></ea431b3f-70a5-4f0e-b5ec-a031e211251a>

這段錯誤訊息,是由以下程式碼所列印:
.task {
do {
作品列表 = try await 搜尋作品列表(搜尋字串)
} catch {
print("網路有問題:\(error)")
}
}

在 do-catch 最後的 { } 段落中,預設會有一個固定的常數,名稱為 error,代表catch捕捉到的錯誤內容。

這個錯誤是因為 macOS 版 App 模式必須手動開啟網路權限,URLSession 才能對外連線,以下是開啟的步驟:

1. 點選App設定
2. 在App設定中,點選「功能」

3. 在「功能」中點選「+」,加入「網路連線(macOS)」

4. 勾選「傳出的網路連線(用戶端)」,點選「加入」

5. 離開「App設定」,這樣網路程式 URLSession 就可以正常執行了:

這裡App設定中的「功能」,相當於 Xcode 裡面的 “Capabilities” 設定(特別是在App Sandbox裡面的),必須額外賦予權限才能使用。目前Swift Playgrounds App模式下,「功能」權限包括以下20種,未來如果在App模式需要時,記得手動開啟:
1. App 傳輸安全性 11. 照片圖庫(僅限加入)
2. App 追蹤透明度 12. 相機
3. Face ID 13. 網路連線(macOS)
4. 使用時的核心位置 14. 總是取用核心位置
5. 區域網路 15. 聯絡人
6. 媒體資料庫(音樂) 16. 藍牙
7. 提醒事項 17. 行事曆
8. 核心動作 18. 語音辨識
9. 檔案取用(macOS) 19. 鄰近互動
10. 照片圖庫 20. 麥克風

注意其中包含「照片圖庫」,也就是上一課4-9e使用的「相簿」,那為什麼上一課的App不需要手動開啟「照片圖庫」權限也能使用呢?

根據原廠文件的說明,PHPicker 或 PhotosPicker 用了一個特別的技巧,稱為 “Out-of-process”,它其實是透過作業系統已有權限的「照片App」去選取相簿,將選取的照片傳送過來而已,(4-9e) App 並沒有直接存取相簿,因此就不需要開啟權限。

💡 註解
  1. 根據Swift官方文件:If a catch clause doesn’t have a pattern, the clause matches any error and binds the error to a local constant named error.
  2. “Capabilities” 字面意思是「能力」(複數型態),與 “ability” 意思接近。在此,App 的 Capabilities 指的是 App 需要獲得系統權限才能具備的功能(或能力),是作業系統的安全機制之一,以防止電腦病毒或惡意軟體。
  3. 設定「功能(Capabilities)」是用來賦予App使用某些被作業系統管制的資源,如(macOS)網路連線、相機、GPS定位…等,對有需要的App都得個別設定一次。
  4. 第3單元的兩個App,「3-9 台灣特有種鳥類」與「3-10 台灣生物多樣性」若要在Swift Playgrounds for macOS 中使用,也要記得手動加上「網路連線」的功能。
4-10b 芝加哥藝術博物館v2 (1)畫家選單(@Binding)

本節開始,我們將使用Swift Playgrounds 的 App 模式(.swiftpm),將「芝加哥藝術博物館」製作成一個App。

還記得在第3單元第5課3-5c「芝加哥藝術博物館」整合版,有個大問題,就是將近300行的程式碼放在一個檔案裡面,變得冗長難以閱讀,這是由於Swift Playgrounds電子書模式下,同一個程式不方便分成多個檔案的緣故。

若改用App模式(.swiftpm),程式「模組化」就容易多了,整個程式可拆成不同Swift檔案,每個檔案做成簡單功能的模塊,如此一來,每個程式模塊可維持小而美,而篇幅越小可讀性越高,就越容易debug,整個程式的品質都會提昇上來。「芝加哥藝術博物館」預計可拆成4-5個Swift程式模塊。

首先,我們將上一節4-10a的「展示作品」分解成兩個模塊:「畫家選單」與「搜尋作品列表」,各自放在一個檔案內。

前面我們利用Picker設計的選單,只有5名畫家:
Picker("芝加哥藝術博物館", selection: $搜尋字串) {
Text("畢卡索(Pablo Picasso)").tag("Pablo Picasso")
Text("莫內(Claude Monet)").tag("Claude Monet")
Text("梵谷(Vincent van Gogh)").tag("Vincent van Gogh")
Text("威廉透納(William Turner)").tag("William Turner")
Text("秀拉(Georges Seurat)").tag("Georges Seurat")
}

如果選單內容的選項比較多,或是選項是變動的,這樣寫就不太合適,更好的寫法是將資料與視圖的程式碼分開,選單裡面的畫家列表是資料,可單獨寫成陣列,視圖的部份再以 ForEach 來產生選單內容。

我們先設計一個描述藝術家的資料類型,當做陣列元素,資料欄位目前只需要畫家的中英文名字:
struct 藝術家: Identifiable {
var id: String { 英文名 }
var 中文名: String
var 英文名: String
}

因為要用在ForEach裡面,須符合Identifiable規範,也就是說必須有 id 欄位,恰好 Picker 選單也使用 id 欄位當做預設的 tag 值,這樣就可省略 .tag() 修飾語了。在此,我們需要 tag 值為畫家的英文名,因此將 id 設為「英文名」欄位值。注意這裡必須用 { } 寫成 “computed property”;若用 = 寫成「var id: String = 英文名」,會產生語法錯誤。

然後宣告一個全域常數「近代西洋畫家」,納入芝加哥藝術博物館收藏的近代著名的西洋畫家列表,形成一個陣列。湊巧的是,周杰倫本月(2022/7)發表的新專輯「最偉大的作品」,提到6位西洋畫家:馬格利特、達利、梵谷、孟克、馬蒂斯、莫內,剛好都在這個列表當中:
let 近代西洋畫家: [藝術家] = [
藝術家(中文名: "威廉透納", 英文名: "William Turner"),
藝術家(中文名: "康斯塔伯", 英文名: "John Constable"),
藝術家(中文名: "德拉克洛瓦", 英文名: "Eugene Delacroix"),
藝術家(中文名: "米勒", 英文名: "Jean Francois Millet"),
藝術家(中文名: "庫爾貝", 英文名: "Gustave Courbet"),
藝術家(中文名: "馬奈", 英文名: "Edouard Manet"),
藝術家(中文名: "雷諾瓦", 英文名: "Pierre-Auguste Renoir"),
藝術家(中文名: "莫內", 英文名: "Claude Monet"),
藝術家(中文名: "竇加", 英文名: "Edgar Degas"),
藝術家(中文名: "塞尚", 英文名: "Paul Cezanne"),
藝術家(中文名: "高更", 英文名: "Paul Gauguin"),
藝術家(中文名: "秀拉", 英文名: "Georges Seurat"),
藝術家(中文名: "梵谷", 英文名: "Vincent van Gogh"),
藝術家(中文名: "羅特列克", 英文名: "Toulouse Lautrec"),
藝術家(中文名: "慕夏", 英文名: "Alphonse Mucha"),
藝術家(中文名: "克林姆", 英文名: "Gustav Klimt"),
藝術家(中文名: "馬蒂斯", 英文名: "Henri Matisse"),
藝術家(中文名: "米羅", 英文名: "Joan Miro"),
藝術家(中文名: "孟克", 英文名: "Edvard Munch"),
藝術家(中文名: "康丁斯基", 英文名: "Wassily Kandinsky"),
藝術家(中文名: "畢卡索", 英文名: "Pablo Picasso"),
藝術家(中文名: "蒙德里安", 英文名: "Piet Mondrian"),
藝術家(中文名: "布拉克", 英文名: "Georges Braque"),
藝術家(中文名: "伍德", 英文名: "Grant Wood"),
藝術家(中文名: "馬格利特", 英文名: "Rene Magritte"),
藝術家(中文名: "達利", 英文名: "Salvador Dalí"),
藝術家(中文名: "安迪沃荷", 英文名: "Andy Warhol")
]

有了資料陣列之後,視圖的部份寫Picker選單就變得簡潔多了:
Picker("芝加哥藝術博物館", selection: $搜尋字串) {
ForEach(近代西洋畫家) { 畫家 in
Text("\(畫家.中文名)(\(畫家.英文名))")
}
}

資料與視圖程式碼分離還有一個好處:資料來源可以多樣化。未來若在網路後端有伺服器,可以建立完整資料庫,再依照藝術風格、區域、年代、喜好等,下載各種JSON格式的選單資料。

不過接下來的發展,會遇到一個問題:「畫家選單」選擇的結果,如何傳回「搜尋作品列表」以便搜尋呢?

過去我們寫的 struct 視圖物件,如果用到別的視圖物件(子視圖),通常是透過參數將初始值帶入子視圖,但有時候需要反向傳遞,在子視圖中操作的結果,透過加上$的參數反向將值帶回父視圖,例如之前用過的 TextField, Button, Picker 等視圖。

在4-10a的「展示作品」視圖中,用到 Picker 與 List 兩個子視圖,視圖階層如下:


對於 List 視圖,我們給的參數「作品列表」是單向唯讀的,作業系統背後會將參數「作品列表」的值複製一份,指定給List內的區域變數,List 裡面的操作,對父視圖的「作品列表」不會有任何影響。這樣的參數傳遞稱為 call-by-value。

對 Picker 視圖而言,參數「$搜尋字串」則是雙向的,除了帶入初始值之外,Picker 選單操作的結果,也會帶回父視圖。背後作業系統實際做法,並不會複製參數值,而是直接將父視圖的「搜尋字串」與子視圖的區域變數(selection)綁成同一個變數(指定到同一個記憶體位置),這個動作稱為 “Binding” (綁定),所以在子視圖操作的結果,會直接修改父視圖的「搜尋字串」值,這樣的參數傳遞稱為 call-by-reference。

所以在上圖中,父視圖「展示作品」先以雙向參數「$搜尋字串」取得子視圖 Picker 選單的結果,再拿「搜尋字串」去連接網路API搜尋取得「作品列表」,交給 List 展示出來。

若將「展示作品」拆成兩個Swift程式檔案,「搜尋作品列表」的關鍵字,必須來自「畫家選單」選擇的結果,彼此間必須能做雙向傳遞參數,如下圖。「畫家選單」如何做出能夠雙向傳遞的參數呢?


做法非常簡單,就是變數宣告時,前面加上「@Binding」,在 Swift 語言中,@符號起頭的稱為”Property Wrapper”(屬性包裝),會增加或改變物件屬性的特質。在 SwiftUI 程式中,@Binding 與 @State 是兩個最常用的屬性包裝,@State 我們已經非常熟悉了,讓屬性變成視圖的狀態,可更新畫面,@Binding 則是用來雙向傳遞,也就是綁定參數。


@Binding 用法與 @State 非常類似,如下,在原來 @State 的地方改成 @Binding 即可:
/// 選擇西洋畫家,取得英文名
struct 畫家選單: View {
@Binding var name: String // 將區域變數(name)宣告為可綁定
var body: some View {
HStack {
Image(systemName: "list.bullet")
Picker("芝加哥藝術博物館", selection: $name) {
ForEach(近代西洋畫家) { 畫家 in
Text("\(畫家.中文名)(\(畫家.英文名))")
}
// .pickerStyle(.inline)
}
}
}
}

這時候,任何使用「畫家選單」的父視圖,就可以用 $ 指定參數,如「畫家選單(name: $搜尋字串)」,選擇結果就會存回「搜尋字串」:
struct 測試_1: View {
@State var 搜尋字串 = "Vincent van Gogh"
var body: some View {
VStack {
畫家選單(name: $搜尋字串) //「搜尋字串」與 name 綁定為同一變數
Spacer()
Image(systemName: "person.crop.artframe")
.resizable()
.scaledToFit()
.frame(height: 200)
Text(搜尋字串)
.font(.largeTitle)
Spacer()
}
}
}

最後完整的程式碼如下,這是整個App第一個程式模塊,後面針對App模式特有的預覽功能,為「畫家選單」視圖寫了一個簡易測試,加入預覽畫面,這樣每個模塊就可邊寫邊測試:
// 4-10(1) 芝加哥藝術博物館v2
// Created (for 4-10a) by Heman, 2022/07/20
import SwiftUI

// Data model for Picker
struct 藝術家: Identifiable {
var id: String { 英文名 }
var 中文名: String
var 英文名: String
}

let 近代西洋畫家: [藝術家] = [
藝術家(中文名: "威廉透納", 英文名: "William Turner"),
藝術家(中文名: "康斯塔伯", 英文名: "John Constable"),
藝術家(中文名: "德拉克洛瓦", 英文名: "Eugene Delacroix"),
藝術家(中文名: "米勒", 英文名: "Jean Francois Millet"),
藝術家(中文名: "庫爾貝", 英文名: "Gustave Courbet"),
藝術家(中文名: "馬奈", 英文名: "Edouard Manet"),
藝術家(中文名: "雷諾瓦", 英文名: "Pierre-Auguste Renoir"),
藝術家(中文名: "莫內", 英文名: "Claude Monet"),
藝術家(中文名: "竇加", 英文名: "Edgar Degas"),
藝術家(中文名: "塞尚", 英文名: "Paul Cezanne"),
藝術家(中文名: "高更", 英文名: "Paul Gauguin"),
藝術家(中文名: "秀拉", 英文名: "Georges Seurat"),
藝術家(中文名: "梵谷", 英文名: "Vincent van Gogh"),
藝術家(中文名: "羅特列克", 英文名: "Toulouse Lautrec"),
藝術家(中文名: "慕夏", 英文名: "Alphonse Mucha"),
藝術家(中文名: "克林姆", 英文名: "Gustav Klimt"),
藝術家(中文名: "馬蒂斯", 英文名: "Henri Matisse"),
藝術家(中文名: "米羅", 英文名: "Joan Miro"),
藝術家(中文名: "孟克", 英文名: "Edvard Munch"),
藝術家(中文名: "康丁斯基", 英文名: "Wassily Kandinsky"),
藝術家(中文名: "畢卡索", 英文名: "Pablo Picasso"),
藝術家(中文名: "蒙德里安", 英文名: "Piet Mondrian"),
藝術家(中文名: "布拉克", 英文名: "Georges Braque"),
藝術家(中文名: "伍德", 英文名: "Grant Wood"),
藝術家(中文名: "馬格利特", 英文名: "Rene Magritte"),
藝術家(中文名: "達利", 英文名: "Salvador Dalí"),
藝術家(中文名: "安迪沃荷", 英文名: "Andy Warhol")
]

/// 選擇西洋畫家,取得英文名
struct 畫家選單: View {
@Binding var name: String
var body: some View {
HStack {
Image(systemName: "list.bullet")
Picker("芝加哥藝術博物館", selection: $name) {
ForEach(近代西洋畫家) { 畫家 in
Text("\(畫家.中文名)(\(畫家.英文名))")
}
// .pickerStyle(.inline)
}
}
}
}

struct 測試_1: View {
@State var 搜尋字串 = "Vincent van Gogh"
var body: some View {
VStack {
畫家選單(name: $搜尋字串)
Spacer()
Image(systemName: "person.crop.artframe")
.resizable()
.scaledToFit()
.frame(height: 200)
Text(搜尋字串)
.font(.largeTitle)
Spacer()
}
}
}

struct 預覽_1: PreviewProvider {
static var previews: some View {
測試_1()
}
}

以下操作影片採用 Swift Playgrounds 4.1 for macOS,示範如何開啟 App 模式,手動設定「網路連線」功能,新增Swift檔案,加入以上程式碼,顯示預覽結果。預設的範例程式”Hello, world!”先留著,等最後完成App時再換掉即可。


💡 註解
  1. Binding 字面為綑綁、綁定、裝訂、黏合等意思,動名詞型態。
  2. 注意「struct 預覽_1」宣告,名稱用了底線符號,在Swift語言中,幾乎所有英文標點符號(包含空格)都有特殊用途,不能用在變數、常數、資料類型、函式等名稱命名中,唯有底線符號例外。
  3. 以下程式碼會出現語法錯誤:

    這段錯誤訊息是什麼意思?
  • 8
內文搜尋
X
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?