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

4-10c App (2) 搜尋作品列表

上一節提到的「資料與程式碼分離」以及「模組化」是程式設計兩個重要方法。這裡所謂的「資料與程式碼分離」,與「物件導向程式設計」將資料與函式包在一起,兩者並不衝突,就像前一節所示範,分離的資料指的是App的「內容」,而不是 struct 裡面需要的欄位變數或物件屬性。

「模組化」程式設計背後的哲學很簡單,就是將複雜的問題分解成若干單元,每個單元再細分為許多小問題,令每個程式模塊解決一個小問題,再由模塊組成模組,最後就能用幾個模組解決複雜問題。

這種做法就像建築大樓一樣,也是科學發展的方法之一,例如醫學將複雜的人體分為神經、消化、呼吸、循環…等幾個系統,每個系統再細分為器官,個別器官就比較容易研究與治療。這種方法之中,越是基礎的東西就越重要,基礎知識越紮實,累積起來的學問就越深厚;如果基礎的程式模塊都沒有bug,整合起來的模組或App就沒有問題。

但是這樣的方法要取得成功,必須有兩個條件:一是問題可以被拆解;二是模塊能夠組合且有效解決問題。

以「芝加哥藝術博物館」為例,在第3單元第4課3-4b就曾經拆解過,將問題分為以下5個步驟:


我們可以按照這樣的分解,來寫程式模塊,最後再組成「芝加哥藝術博物館」App。但是要如何確保模塊能順利組合呢?其實也很簡單,就是讓每個程式模塊都有明確的輸入、輸出介面,就像樂高積木一樣,如下圖:


對函式而言,每個函式都可定義輸入參數與回傳(輸出)的資料類型,所以函式很適合成為模塊;另外,有了上一節介紹的 @Binding,用 struct 定義的物件或視圖也有輸入與輸出的屬性,成為第二種程式模塊。

簡單的說,如果需要顯示畫面,就用 struct 視圖來構築模塊;若程式模塊要在背景執行(如非同步模式),不需要顯示畫面,就以函式來作為模塊。

在接下來第二步驟「搜尋作品列表」與第三步驟「取得作品資訊」,都是在背景用網路連接博物館的API,所以適合寫成函式模塊。


拆解出來的程式碼如下,成為App的第2個Swift檔案,主要模塊是「搜尋作品列表()」函式,同樣改成 async throws 函式,並增加一個參數max,限制回傳作品筆數。後面同樣寫一段程式作為預覽,以測試「畫家選單」與「搜尋作品列表」兩個模塊的組合:
// 4-10(2) 芝加哥藝術博物館v2
// Created (for 3-4a) by Heman, 2021/10/02
// Revised (for 4-10a) by Heman, 2022/07/16
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
}

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

struct 測試_2: View {
@State var 搜尋字串 = "Vincent van Gogh"
@State var 作品列表: [搜尋品項] = []
var body: some View {
VStack {
畫家選單(name: $搜尋字串)
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品列表 = try await 搜尋作品列表(搜尋字串, max: 20)
} catch {
print("網路有問題:\(error)")
}
}
}
List(作品列表) { 作品 in
Label(作品.title, systemImage: "rectangle.portrait")
.font(.title2)
.lineLimit(1)
}
.task {
do {
作品列表 = try await 搜尋作品列表(搜尋字串, max:
20)
} catch {
print("網路有問題:\(error)")
}
}
}
}
}

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


預覽結果如下圖,完全等同4-10a的執行結果,表示拆解與組合是有效的:
4-10d App(3) 取得作品資訊

芝加哥藝術博物館的Open API設計得相當好,不但功能完整,網站服務也非常穩定,對於未註冊的匿名使用者,也很大方的開放(每分鐘最多60次連線,正適合練習),很值得國內的博物館或政府開放資料(Open Data)的單位借鑑。

在前一步驟取得「作品列表」之後,還需要再連接一次API與傑森解碼器,這次的目的是透過作品的「api_link」取得作品詳細資訊,將其中的 image_id 組成「圖檔網址」,以便下個步驟載入圖片。

原本在第3單元的寫法如下,將「更新作品列表」與「取得作品資訊」兩個函式,直接寫在「芝加哥藝術博物館」視圖裡面,除了輸入參數之外,函式並沒有回傳資料,而是將結果寫入物件屬性「作品圖集」當中。
struct 芝加哥藝術博物館: View {
@State var inputText = "van gogh"
@State var 搜尋字串 = "van gogh"
@State var 作品列表: [搜尋品項]?
@State var 作品圖集: [作品圖] = []

func 更新作品列表(_ 字串參數: String) {
...
}

func 取得作品資訊(_ myURL: URL) {
var 單項作品: 作品資訊?
var 目前作品 = 作品圖()
var myURLComponent = URLComponents()
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 待解碼資料 = 回傳資料 {
do {
let 尺寸 = "600,"
let 解碼結果 = try JSONDecoder().decode(藝術作品.self, from: 待解碼資料)
print(回傳碼 ?? "No response")
單項作品 = 解碼結果.data
目前作品.id = 單項作品!.id
目前作品.作品名稱 = 單項作品!.title
目前作品.作者 = 單項作品!.artist_display
myURLComponent.scheme = "https"
myURLComponent.host = "www.artic.edu"
myURLComponent.path = "/iiif/2/\(單項作品!.image_id)/full/\(尺寸)/0/default.jpg"
myURLComponent.query = ""
目前作品.圖檔網址 = myURLComponent.url
作品圖集 = 作品圖集 + [目前作品]
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}
...
}

要把函式抽離出來,就得將完整的輸入、輸出介面定義清楚,可以仿照上一節4-10(2)的作法,定義「取得作品資訊」函式如下:
struct 作品圖: Identifiable {
let id: Int
let 作品名稱: String
let 作者: String
let 圖檔網址: URL?
}

func 取得作品資訊(_ myURL: URL) async throws -> 作品圖 {
...
}

不過,這裡還有更好的做法,就是將「作品圖」與「取得作品資訊()」結合成新的物件。

「取得作品資訊()」輸入參數為作品api_link網址,輸出為「作品圖」結構,因此函式與「作品圖」結合之後,「取得作品資訊()」恰可做為物件的初始化函式init()。新的寫法如下:
struct 作品圖: Identifiable {
let id: Int
let 作品名稱: String
let 作者: String
let 圖檔網址: URL?
// 取得作品資訊:
init(_ myURL: URL) async throws {
let (回傳資料, _) = try await URLSession.shared.data(from: myURL)
let 取得作品 = try JSONDecoder().decode(藝術作品.self, from: 回傳資料)
var 組合網址 = URLComponents()
組合網址.scheme = "https"
組合網址.host = "www.artic.edu"
組合網址.path = "/iiif/2/\(取得作品.data.image_id)/full/843,/0/default.jpg"
id = 取得作品.data.id
作品名稱 = 取得作品.data.title
作者 = 取得作品.data.artist_display
圖檔網址 = 組合網址.url
}
}

注意到「作品圖」物件的「初始化函式」init() 仍然可以寫成 async throws,和一般非同步函式一樣。要使用時,同樣用 Task-do-try-await-catch 的句型,以 Task { } 包住一段非同步程式段落,執行時整個 Task 會移到背景,do-try-catch 則是用來攔截錯誤。這一小段程式如下,可用來測試預覽:
Task {
do {
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 20)
for 作品 in 作品列表 {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
}
} catch {
print("網路有問題:\(error)")
}
}

完整包含測試預覽的程式如下,我們仍用 List 來展現取得的作品列表,和上一節類似,但每個作品名稱加上網址連接,點選作品名稱時,會帶出瀏覽器載入圖片,在此範例初次使用到 Link 視圖:
// 4-10(3) 取得作品資訊
// Created by Heman, 2022/07/25
import SwiftUI

// 2nd data model for JSONDecoder()
// Sample URL https://api.artic.edu/api/v1/artworks/28560
struct 藝術作品: Codable {
let data: 作品資訊
let config: 配置資訊
}

struct 作品資訊: Codable, Identifiable {
let id: Int
let api_link: URL?
let title: String
let date_display: String
let artist_display: String
let artist_id: Int
let artist_title: String
let image_id: String
}

struct 配置資訊: Codable {
let iiif_url: URL?
let website_url: URL?
}

// 作品圖
// 「圖檔網址」採用 IIIF v2 標準格式,範例如下:
// https://www.artic.edu/iiif/2/25c31d8d-21a4-9ea1-1d73-6a2eca4dda7e/full/843,/0/default.jpg
// https://www.artic.edu/iiif/2/25c31d8d-21a4-9ea1-1d73-6a2eca4dda7e/full/max/0/default.jpg
// 其中 "25c31d8d-21a4-9ea1-1d73-6a2eca4dda7e" 為 image_id
struct 作品圖: Identifiable {
let id: Int
let 作品名稱: String
let 作者: String
let 圖檔網址: URL?
// 取得作品資訊:
// let 網址 = "https://api.artic.edu/api/v1/artworks/28560"
// guard let myURL = URL(string: 網址) else { return }
init(_ myURL: URL) async throws {
let (回傳資料, _) = try await URLSession.shared.data(from: myURL)
let 取得作品 = try JSONDecoder().decode(藝術作品.self, from: 回傳資料)
// print(取得作品.data)
var 組合網址 = URLComponents()
組合網址.scheme = "https"
組合網址.host = "www.artic.edu"
組合網址.path = "/iiif/2/\(取得作品.data.image_id)/full/843,/0/default.jpg"
id = 取得作品.data.id
作品名稱 = 取得作品.data.title
作者 = 取得作品.data.artist_display
圖檔網址 = 組合網址.url
}
}

struct 測試_3: View {
@State var 搜尋字串 = "Vincent van Gogh"
// @State var 作品列表: [搜尋品項] = []
@State var 作品圖列表: [作品圖] = []
var body: some View {
VStack {
畫家選單(name: $搜尋字串)
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品圖列表 = []
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 20)
for 作品 in 作品列表 {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
}
} catch {
print("網路有問題:\(error)")
}
}
}
List(作品圖列表) { 作品 in
Link(destination: 作品.圖檔網址!) {
Text(作品.作品名稱)
.font(.title2)
}
}
.task {
do {
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 20)
for 作品 in 作品列表 {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
}
} catch {
print("網路有問題:\(error)")
}
}
}
}
}

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

測試預覽的主畫面,由 List + Link 組成,List 不需要另外寫出 id 欄位,因為「作品圖列表」已符合 Identifiable 規範。只要特別注意初次使用的 Link 視圖語法,其實就是用 destination 參數,給文字加上網址連結(與上一節比較,帶連結的文字,預設顏色會不一樣):
List(作品圖列表) { 作品 in
Link(destination: 作品.圖檔網址!) {
Text(作品.作品名稱)
.font(.title2)
}
}

以下影片示範點擊 Link 連結,帶出瀏覽器畫面的效果:
4-10e App (4) 載入圖片

上一節將「取得作品資訊()」與「作品圖」合成一個物件,是物件導向程式設計很重要的一步,同時也考驗大家對「物件類型」、「物件實例」、「初始化函式」等基本觀念的理解。

當我們用 struct 定義物件類型時,相當於製作了一個模子,這個模子決定未來的個體(實例)長什麼樣子(屬性)、有什麼功能(函式)。

一個模子能複製出很多個體,產出個體的過程,其實就是「初始化」,也就是給每個屬性初始值。不同個體之間,同一屬性有不同的初始值,代表個體的細部差異,就像人類個體剛出生,就有膚色、毛髮、體重…等差別。只有當物件的每個屬性都有初始值之後,物件個體才開始存在。

這也是為什麼在 struct 定義物件時,屬性的預設值不能指定為另一個屬性的原因,如下圖例。在物件實例產出之前,無法取用任何屬性值,因為個體(物件實例)還未成形。同理,在初始化函式init()中,也不能讀取自己的任何屬性。


這段錯誤訊息:Cannot use instance member ‘英文名’ within property initializer; property initializers run before ‘self’ is available. 現在應該比較能理解了,’self’ 指的就是物件個體的自我,自我還未成形,屬性當然無法使用。

上節定義的「作品圖」物件,每個屬性都在初始化函式中獲得初始值,包括最後一個屬性「圖檔網址」。有了「圖檔網址」,其實離載入圖片僅一步之遙,不如就將下載的「圖片」也加入屬性之中。

那麼「圖片」屬性如何初始化呢?其實只要多兩行程式碼就行,init() 修改如下(最後兩行):
// 作品圖(v2)
struct 作品圖: Identifiable {
let id: Int
let 作品名稱: String
let 作者: String
let 圖檔網址: URL?
let 圖片: UIImage?

init(_ myURL: URL) async throws {
let (回傳資料, _) = try await URLSession.shared.data(from: myURL)
let 取得作品 = try JSONDecoder().decode(藝術作品.self, from: 回傳資料)
// print(取得作品.data)
var 組合網址 = URLComponents()
組合網址.scheme = "https"
組合網址.host = "www.artic.edu"
組合網址.path = "/iiif/2/\(取得作品.data.image_id)/full/843,/0/default.jpg"
id = 取得作品.data.id
作品名稱 = 取得作品.data.title
作者 = 取得作品.data.artist_display
圖檔網址 = 組合網址.url
let (內容, _) = try await URLSession.shared.data(from: 組合網址.url!)
圖片 = UIImage(data: 內容)

}
}

如此一來,「作品圖」僅需提供(輸入)作品的api_link,就能得到(輸出)作品資訊以及圖片檔,後續要展示圖片與作品資訊就容易多了。

不過要注意的是,這個初始化函式是 async throws,非同步且可能拋出錯誤。其中有三個 try 的地方可能會出現錯誤而中斷初始化過程(物件實例就不會產出),因此在使用的時候(透過Task-do-try-await-catch標準句型),必須多加小心。

另外,前面提到「只有當物件的每個屬性都有初始值之後,物件個體才開始存在」,其實有例外,在上面程式碼之中,有兩個屬性是 “Optional”,也就是加上問號 ? 的類型,就是例外情況,也唯有 Optional 類型允許未初始化的屬性。

例如,上面最後一行用 UIImage 將下載內容轉換成圖片格式,回傳的類型就是 UIImage?,UIImage 支援多種圖形格式,例如 JPG, GIF, PNG, …等,如果下載的資料並非支援的圖形格式,就會回傳 nil,表示沒有初始值,這種情況下,物件實例仍會產出,所以在使用時,要記得測試這種情況,例如: if 圖片 != nil { … } 。

以下完整程式,其中就用到 if 作品.圖片 != nil,確認「圖片」有初始值,才加以顯示,否則若用到未初始化的屬性,下場只有死路一條,App 會立即閃退。
// 4-10(4) 載入圖片
// Created by Heman, 2022/07/28
import SwiftUI

struct 載入圖片: View {
@State var 搜尋字串 = "Vincent van Gogh"
@State var 作品圖列表: [作品圖] = []
// @State var 圖片集: [UIImage] = []

var body: some View {
ZStack(alignment: .topLeading) {
List(作品圖列表) { 作品 in
if 作品.圖片 != nil {
VStack {
Image(uiImage: 作品.圖片!)
.resizable()
.scaledToFit()
HStack {
Label("\(作品.作品名稱)", systemImage: "rectangle.portrait")
.font(.title3)
Spacer()
Text(作品.作者)
.font(.caption)
}
}
}
}
畫家選單(name: $搜尋字串)
.background(Color.gray.opacity(0.2))
.task {
do {
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 5)
for 作品 in 作品列表 {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
}
} catch {
print("網路有問題:\(error)")
}
}
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品圖列表 = []
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 5)
for 作品 in 作品列表 {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
}
} catch {
print("網路有問題:\(error)")
}
}
}
}
}
}

struct 預覽_4: PreviewProvider {
static var previews: some View {
載入圖片()
// Text("App Part 4")
}
}

在此範例,我們試著產出「作品圖」實例,若因為錯誤而未產出,則列印錯誤訊息,若順利產出,則加入「作品圖列表」。

所以如果因為網路問題(如超過連線次數)而產生錯誤,只會導致少一筆「作品圖」而已,App仍可繼續正常運作。

主畫面我們改用 ZStack 來安排「畫家選單」與 List 視圖,將「畫家選單」浮在螢幕左上角,以便讓List 顯示的圖片列表,佔滿全螢幕空間。

以下影片為修改程式及預覽結果:


到目前為止,我們已成功拆解第3單元3-4b的「芝加哥藝術博物館」,分成4個Swift檔案,執行結果也相當符合3-4b的視圖外觀(除了將 TextField 搜尋改成 Picker 選單)。

下一節就可以將前一課的「圖片輪播」整合進來。

💡 註解
  1. 物件屬性的初始值與預設值有何差別?下面例子中,「點座標」預設值為(0, 0),若產出物件實例時未提供初始值,則以預設值為初始值。

  2. “optional” 意思是「可選擇的」「有選擇性的」,名詞 “option” 是選項或選擇,例如:”They don't leave us many options.”。
  3. 上面提到宣告物件屬性時,「屬性的預設值不能指定為另一個屬性」,因為個體初始化未完成之前,無法讀取任何屬性值。但改成 computed property 就可以,為什麼呢?
    // Data model for Picker
    struct 藝術家: Identifiable {
    var id: String { 英文名 }
    var 中文名: String
    var 英文名: String
    }
補充:除錯(Debug)

上一節的App(Part4)發現一個bug,當畫家選擇「慕夏」時,會出現錯誤訊息,而且沒有任何圖片出現。

仔細檢查錯誤訊息:
網路有問題:valueNotFound(Swift.String, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "data", intValue: nil), CodingKeys(stringValue: "image_id", intValue: nil)], debugDescription: "Expected String value but found null instead.", underlyingError: nil))

大致猜得出來,應該是傑森解碼器拋出的錯誤,提到 “data” 與 “image_id” 兩欄出現 nil (無初始值)。

這兩欄對應的位置在App(3)「作品圖」所用的傑森解碼器:
struct 藝術作品: Codable {
let data: 作品資訊
let config: 配置資訊
}

struct 作品資訊: Codable, Identifiable {
let id: Int
let api_link: URL?
let title: String
let date_display: String
let artist_display: String
let artist_id: Int
let artist_title: String
let image_id: String
}

struct 配置資訊: Codable {
let iiif_url: URL?
let website_url: URL?
}

struct 作品圖: Identifiable {
let id: Int
let 作品名稱: String
let 作者: String
let 圖檔網址: URL?
let 圖片: UIImage?

init(_ myURL: URL) async throws {
let (回傳資料, _) = try await URLSession.shared.data(from: myURL)
let 取得作品 = try JSONDecoder().decode(藝術作品.self, from: 回傳資料)
// print(取得作品.data)
var 組合網址 = URLComponents()
組合網址.scheme = "https"
組合網址.host = "www.artic.edu"
組合網址.path = "/iiif/2/\(取得作品.data.image_id)/full/843,/0/default.jpg"
id = 取得作品.data.id
作品名稱 = 取得作品.data.title ?? ""
作者 = 取得作品.data.artist_display ?? ""
圖檔網址 = 組合網址.url
let (內容, _) = try await URLSession.shared.data(from: 組合網址.url!)
圖片 = UIImage(data: 內容)
}
}

再仔細看芝加哥藝術博物館的API文件,裡面提到:

Documentation | Art Institute of Chicago API
• If a field that typically returns a string, number, or object is empty for a given record, we return it as `null`, rather than omitting it.

以上表示,某些欄位的確可能沒有資料,在JSON格式其欄位值為 null,Swift 會轉換為 nil。

所以應該是下載的某一筆 JSON 資料(回傳資料)裡面,image_id 欄位沒有資料,由於我們定義的 image_id 並非 Optional,無法指定為 nil,導致解碼失敗而拋出錯誤碼。

但是,我們希望若只有一筆錯誤,不會影響到其他筆資料才對,怎麼會出現完全空白的情況呢?再仔細檢查App(4) for迴圈的邏輯:
Task {
do {
作品圖列表 = []
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 5)
for 作品 in 作品列表 {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
}
} catch {
print("網路有問題:\(error)")
}
}

結果發現,如果for迴圈裡面的「作品圖()」初始化失敗,拋出錯誤後會完全跳離for迴圈,進入 catch { } 段落,再也不會回到for迴圈了!

因此,我們應該寫兩個 do-catch 句型,外圈是針對「搜尋作品列表()」,內圈要寫在for迴圈裡面,針對「作品圖()」,這樣「作品圖」的錯誤才不會跳出for迴圈:
Task {
do {
作品圖列表 = []
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 5)
for 作品 in 作品列表 {
do {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
} catch {
print("「作品圖」初始化失敗:\(error)")
}
}
} catch {
print("網路有問題:\(error)")
}
}

果然,這樣修正之後,「慕夏」就恢復正常了,五筆記錄只有一筆錯誤,其他四筆作品都正常顯示出來。


修正後的 App(4) 完整程式碼如下:
// 4-10(4) 載入圖片
// Created by Heman, 2022/07/28
// Revised by Heman, 2022/08/02
import SwiftUI

struct 載入圖片: View {
@State var 搜尋字串 = "Vincent van Gogh"
@State var 作品圖列表: [作品圖] = []
// @State var 圖片集: [UIImage] = []

var body: some View {
ZStack(alignment: .topLeading) {
List(作品圖列表) { 作品 in
if 作品.圖片 != nil {
VStack {
Image(uiImage: 作品.圖片!)
.resizable()
.scaledToFit()
HStack {
Label("\(作品.作品名稱)", systemImage: "rectangle.portrait")
.font(.title3)
Spacer()
Text(作品.作者)
.font(.caption)
}
}
}
}
畫家選單(name: $搜尋字串)
.background(Color.gray.opacity(0.2))
.task {
do {
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 5)
for 作品 in 作品列表 {
do {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
} catch {
print("「作品圖」初始化失敗:\(error)")
}
}
} catch {
print("網路有問題:\(error)")
}
}
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品圖列表 = []
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 5)
for 作品 in 作品列表 {
do {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
} catch {
print("「作品圖」初始化失敗:\(error)")
}
}
} catch {
print("網路有問題:\(error)")
}
}
}
}
}
}

struct 預覽_4: PreviewProvider {
static var previews: some View {
載入圖片()
// Text("App Part 4")
}
}

回頭看,同樣的錯誤也在App(3)的「測試_3」出現過,就當成作業讓讀者練習除錯。

💡 註解
  1. null 與 nil 英文都是「空無」「虛無」的意思,在大部分的程式語言中,用來代表「無資料」或「未初始化」,在 JSON 格式中用的是 null,對應Swift 語言為 nil 。
  2. 注意空字串 “” 與空陣列 [] 可以當做有效的初始值,並不是 nil。
  3. 若將 image_id 定義為 Optional,程式邏輯會有何改變?
  4. 捷克畫家慕夏(Mucha, 1860-1939)是筆者最喜歡的畫家之一,如果看不到作品實在太可惜。捷克「慕夏基金會」去(2021)年底至今年四月曾在台北中正紀念堂舉行「慕夏特展」,展出慕夏的相關作品及部分真跡手稿。
4-10f 芝加哥藝術博物館App (Final) 圖片輪播

前一節4-10e「作品圖」加入圖片屬性之後,使用芝加哥藝術博物館的圖片資料就變得非常方便,在上一課4-9圖片輪播,剛好需要一組圖片陣列當做輸入,透過「作品圖」物件,很容易就能準備好圖片陣列,而且還多了作品資訊,本節就將圖片加上作品資訊一起輪播。

整個構想的視圖階層如下,主畫面用ZStack疊加三層:底層以全黑為背景,中層顯示TimelineView輪播的圖片,上層再加上畫家選單、標題、作者、輪播標記等文字。


大致上只要將前一節4-10(4)的 List 列表改成4-9b的 TimelineView 即可,但是須增加一個參數「作品圖列表」,給畫布顯示圖片,參數以綁定(@Binding)方式雙向傳遞:
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date, 列表: $作品圖列表)
}

在原來4-9b的圖片輪播,只需用 TimelineView(.animation) 驅動畫布(每秒更新60次),就可達到自動輪播的動畫效果,輪播的圖片集都是在畫布Canvas下載。而這次我們改在主畫面顯示「畫家選單」,然後下載「作品圖列表」,再送進畫布Canvas:
struct 圖片輪播: View {
@State var 搜尋字串 = "Vincent van Gogh"
@State var 作品圖列表: [作品圖] = []

var body: some View {
ZStack(alignment: .topLeading) {
Color(.black)
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date, 列表: $作品圖列表)

}
畫家選單(name: $搜尋字串)
.foregroundColor(.yellow)
.background(Color.gray.opacity(0.2))
.task {
do {
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 9)
for 作品 in 作品列表 {
do {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)

} catch {
print("「作品圖」初始化失敗:\(error)")
}
}
} catch {
print("網路有問題:\(error)")
}
}
.onChange(of: 搜尋字串) { _ in
...
}
}
}
}

理論上,參數「作品圖列表」也可單向傳遞給畫布,但因為 TimelineView(.animation) 每秒會更新60次,也就是每秒傳遞參數60次,若用單向參數傳遞(參數值會被複製)太頻繁,會導致執行效能變差(尤其參數中包含圖片陣列),改成雙向 @Binding 即可避免此問題。

@Binding 宣告在「畫布」視圖中,「畫布」的架構與4-9b幾乎相同,但這次我們將輪播方向改為垂直滑動,原本在底部的播放標記改到右側垂直排列,也只需修改幾行而已。
struct 畫布: View {
let 更新: Date
@Binding var 列表: [作品圖]
// @State var 圖片集: [UIImage] = []
@State var 百分比: CGFloat = 0.0
@State var 張次 = 0

var body: some View {
Canvas { 圖層, 尺寸 in
let 中心 = CGPoint(x: 尺寸.width/2, y: 尺寸.height/2)
if 列表.isEmpty {
圖層.draw(
Text("等待圖片下載...").foregroundColor(.white),
at: 中心,
anchor: .center)
} else {
for i in 0..<3 { // 僅需顯示前3張圖片
if i < 列表.endIndex { // 避免只有1-2張的情況
let 圖片 = 列表[i].圖片 ?? UIImage(systemName: "photo.artframe")
let 照片 = 圖層.resolve(Image(uiImage: 圖片!))
let 寬度比 = 尺寸.width / 照片.size.width
let 高度比 = 尺寸.height / 照片.size.height
let 縮放比 = min(寬度比, 高度比)
let 移動距離 = 百分比 < 1.0 ? 尺寸.height * 百分比 : 尺寸.height
圖層.drawLayer { 圖片層 in
圖片層.translateBy(
x: 中心.x,
y: 中心.y + 尺寸.height * CGFloat(i) - 移動距離)
if 縮放比 < 1.0 {
圖片層.scaleBy(x: 縮放比, y: 縮放比)
}
圖片層.draw(照片, at: .zero)
// print(尺寸, 圖片層.transform)
}
}
}
圖層.drawLayer { 標記層 in
let 右側 = CGPoint(
x: 尺寸.width - 20,
y: 中心.y)
var 標記 = AttributedString("")
for i in 0..<列表.count {
標記 += (i == 張次) ? "●\n" : "○\n"
}
標記.foregroundColor = .white
標記.backgroundColor = .gray.opacity(0.2)
標記層.draw(Text(標記), at: 右側)
}
}
}
}
}

畫布中,除了圖片與輪播標記之外,要再加入作品名稱與畫家名字:
圖層.drawLayer { 標題層 in
標題層.translateBy(
x: 中心.x,
y: 中心.y + 尺寸.height * CGFloat(i) - 移動距離)
let r = 0.8
let 標題框 = CGRect(
x: -尺寸.width * r / 2,
y: -尺寸.height * r / 2,
width: 尺寸.width * r / 2,
height: 尺寸.height * r / 2)
var 標題 = AttributedString(列表[i].作品名稱)
標題.font = .title3
標題.foregroundColor = .white
標題.backgroundColor = .gray.opacity(0.2)
標題層.draw(Text(標題), in: 標題框)
let 作者框 = CGRect(
x: 尺寸.width * (1.0 - r) / 2.0,
y: 尺寸.height * r / 2,
width: 尺寸.width * r / 2,
height: 尺寸.height * r / 2)
var 作者 = AttributedString(列表[i].作者)
作者.font = .caption
作者.foregroundColor = .white
作者.backgroundColor = .gray.opacity(0.2)
標題層.draw(Text(作者), in: 作者框)
}

同樣將文字畫在新圖層,作品名稱(標題框)放在左上角,畫家名字(作者框)放在右下角,這兩塊文字的位置,是以原點(螢幕左上角)對準圖片中心來計算,再配合位移(translateBy)移到正確位置(如圖)。


雖然是個小細節,但要計算好位置卻不簡單。

以下是完整程式列表,最後這一部分長達150行,比較難拆解:
// 4-10(5) 自動輪播(垂直滑動)
// Created for 4-9b by Heman, 2022/06/20
// Revised for 4-10(5) by Heman, 2022/08/04
import SwiftUI

struct 畫布: View {
let 更新: Date
@Binding var 列表: [作品圖]
// @State var 圖片集: [UIImage] = []
@State var 百分比: CGFloat = 0.0
@State var 張次 = 0

var body: some View {
Canvas { 圖層, 尺寸 in
let 中心 = CGPoint(x: 尺寸.width/2, y: 尺寸.height/2)
if 列表.isEmpty {
圖層.draw(
Text("等待圖片下載...").foregroundColor(.white),
at: 中心,
anchor: .center)
} else {
for i in 0..<3 { // 僅需顯示前3張圖片
if i < 列表.endIndex { // 避免只有1-2張的情況
let 圖片 = 列表[i].圖片 ?? UIImage(systemName: "photo.artframe")
let 照片 = 圖層.resolve(Image(uiImage: 圖片!))
let 寬度比 = 尺寸.width / 照片.size.width
let 高度比 = 尺寸.height / 照片.size.height
let 縮放比 = min(寬度比, 高度比)
let 移動距離 = 百分比 < 1.0 ? 尺寸.height * 百分比 : 尺寸.height
圖層.drawLayer { 圖片層 in
圖片層.translateBy(
x: 中心.x,
y: 中心.y + 尺寸.height * CGFloat(i) - 移動距離)
if 縮放比 < 1.0 {
圖片層.scaleBy(x: 縮放比, y: 縮放比)
}
圖片層.draw(照片, at: .zero)
// print(尺寸, 圖片層.transform)
}
圖層.drawLayer { 標題層 in
標題層.translateBy(
x: 中心.x,
y: 中心.y + 尺寸.height * CGFloat(i) - 移動距離)
let r = 0.8
let 標題框 = CGRect(
x: -尺寸.width * r / 2,
y: -尺寸.height * r / 2,
width: 尺寸.width * r / 2,
height: 尺寸.height * r / 2)
var 標題 = AttributedString(列表[i].作品名稱)
標題.font = .title3
標題.foregroundColor = .white
標題.backgroundColor = .gray.opacity(0.2)
標題層.draw(Text(標題), in: 標題框)
let 作者框 = CGRect(
x: 尺寸.width * (1.0 - r) / 2.0,
y: 尺寸.height * r / 2,
width: 尺寸.width * r / 2,
height: 尺寸.height * r / 2)
var 作者 = AttributedString(列表[i].作者)
作者.font = .caption
作者.foregroundColor = .white
作者.backgroundColor = .gray.opacity(0.2)
標題層.draw(Text(作者), in: 作者框)
}
}
}
圖層.drawLayer { 標記層 in
let 右側 = CGPoint(
x: 尺寸.width - 20,
y: 中心.y)
var 標記 = AttributedString("")
for i in 0..<列表.count {
標記 += (i == 張次) ? "●\n" : "○\n"
}
標記.foregroundColor = .white
標記.backgroundColor = .gray.opacity(0.2)
標記層.draw(Text(標記), at: 右側)
}
}
}
// .border(.red)
.onChange(of: 更新) { _ in
let 輪播週期 = 10.0
let 移動速率 = 0.05
if 列表.count > 1 { // 至少2張才需要輪替
if 百分比 > 輪播週期 { // 替換下一張
張次 = (張次 + 1) % 列表.count
if let 首張 = 列表.first { // 將第一張移至最後
列表.removeFirst()
列表.append(首張)
}
百分比 = 0.0
} else {
百分比 += 移動速率
}
}
}
}
}

struct 圖片輪播: View {
@State var 搜尋字串 = "Vincent van Gogh"
@State var 作品圖列表: [作品圖] = []

var body: some View {
ZStack(alignment: .topLeading) {
Color(.black)
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date, 列表: $作品圖列表)
}
畫家選單(name: $搜尋字串)
.foregroundColor(.yellow)
.background(Color.gray.opacity(0.2))
.task {
do {
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 9)
for 作品 in 作品列表 {
do {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
} catch {
print("「作品圖」初始化失敗:\(error)")
}
}
} catch {
print("網路有問題:\(error)")
}
}
.onChange(of: 搜尋字串) { _ in
Task {
do {
作品圖列表 = []
let 作品列表 = try await 搜尋作品列表(搜尋字串, max: 9)
for 作品 in 作品列表 {
do {
let 作品資訊 = try await 作品圖(作品.api_link!)
作品圖列表.append(作品資訊)
} catch {
print("「作品圖」初始化失敗:\(error)")
}
}
} catch {
print("網路有問題:\(error)")
}
}
}
}
}
}

最後將 MyApp 主畫面改為「圖片輪播()」,就完成整個App了!
import SwiftUI

@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
圖片輪播()
}
}
}

若使用Swift Plagyrounds for macOS版,還可以安裝App到自己的Mac電腦裡面,成為一支獨立的程式,不需要透過Swift Playgrounds就能執行。App預覽、執行及安裝到Mac的過程,可參考以下影片:
第4單元結語

第4單元到此告一段落,比預期多花一個多月,尤其最後一課(4-10)芝加哥藝術博物館App,寫得正順手,有點欲罷不能,還有好多功能沒有寫進去,博物館30多萬件藏品,我們也只欣賞不到300件(0.1%),後續發展空間還很大。

其實很多成功的 App 就像這樣,一開始功能很簡單,但留下升級空間,如果初期反應不錯,就逐步發展壯大,慢慢完善各種功能,最後發展成一個平台,融合各種交易、工具、遊戲、服務…等,可以一路成長上去。

這個App功能雖少,但也拆成5個Swift檔案,共寫了500行程式碼,做為一個教學範例來說,相當於期末專案了,從這個App的建構過程,我們學到幾個寶貴經驗:

1. 基本觀念非常重要,基礎若不紮實,到後面觀念就很容易混淆。
2. 程式設計要注重細節,差一個關鍵字、錯一個標點符號,都可能致命。
3. 不管老鳥菜鳥,寫程式一定會有bug,要學會自己 debug。
4. 若想寫大型程式,必須培養問題拆解與模組建構能力。

本單元從一開始介紹文字為主的動畫,包括Animation物件、帶屬性文字AttributedString、Markdown 標示、文字分解等,前半單元除了 Animation 之外,附帶的學習重點就是文字處理,熟悉文字處理對App來說非常重要,因為文字是表達思想意涵最簡便的媒介,千萬不要因為圖像或影片的流行而忽略文字。

後半單元著重於時間軸 TimelineView + Canvas 畫布的組合,能實現任何2D的繪圖與動畫,功能非常強大。這兩個視圖是2021年才發布,但是背後的技術框架已超過十年,非常成熟。2D繪圖最基本從畫圓與三角形開始,再利用三角函數畫出各種對稱圖案。

整個單元學習下來,能有多少收穫,就看讀者自己的努力了。最簡單的學習方法,就是將程式碼照打一遍,千萬不要複製貼上,因為在打字過程,不但可以學到語法細節(特別是標點符號),還可觸摸到筆者的思路,有些段落甚至重複多次,成為標準句型(值得背下來)。

若從第1單元逐步跟到這裡的,應該跟筆者一樣,收穫巨大,至此可算對Swift程式語言以及SwiftUI框架,有了基本的認識,能獨立完成簡單的App,具備將概念化為實作的基本能力了。

本系列課程從第1單元開始,一直都以 Swift Playgrounds 為開發環境,這個App也持續受到Apple原廠高度重視,與 SwiftUI 一樣,每年都有相當大的進步,目前已能夠開發 iOS 與 macOS 的 App,實在是可喜可賀,表示筆者的判斷沒有錯,Swift Playgrounds 就是專為程式設計初學者與學生所打造,完全符合本課程的定位。

第4單元當然不是最後一個單元,後續可以寫的教學題目還很多,不過需要一些構想與測試時間,下個單元預計在(2022)年底,等 Swift Playgrounds 下個版本推出之後開始寫,題目屆時再公布。

若對下個單元有任何建議,或對本單元學習有問題,筆者未必即時在Mobile01上看到,可留email 給我: haimin.lu@gmail.com ,祝大家學習愉快,不斷綻放生命之花。
圖說:4-8c 生命之花(n=16)
番外篇:我的Mac初體驗

1986年10月台大活動中心舉行一場「資訊週」展覽,由資訊工程系主辦,展出內容包括IBM PC 以及許舜欽教授「電腦下象棋」、李琳山教授「電腦說國語」等PC軟體,最特別的還有一台傳說中的Apple Macintosh 「麥金塔」電腦,這是筆者第一次接觸到Mac。

1980年代是PC剛萌芽,電腦開始進入社會的啟蒙階段,在這之前,電腦(或稱「電子計算機」)雖已發展30-40年,由於價格昂貴,只有軍方、學術、政府單位或大企業才用得起,商業電腦從大型電腦(mainframe)發展到工作站(workstation),售價都不是一般家庭買得起的,直到 Apple 1977年推出 Apple II 大受歡迎,隨後 IBM 1981年推出 Personal Computer (IBM PC),才逐漸進入一般人視野中。

在台灣,行政院政務委員李國鼎1979年成立資策會,開始每年在松山機場外貿中心(世貿中心還未成立)舉辦「資訊週」電腦展,讓一般民眾有機會接觸電腦,「電腦」一詞由范光陵先生在1960年代翻譯,但直到1980年代民眾才逐漸知道「電腦」是做什麼的。

1986年筆者做為資工系新生,被派去活動中心「資訊週」幫忙,並且自告奮勇留下來值夜一個晚上,特別對那台外形小巧,9吋單色螢幕,卻有精緻圖形介面的麥金塔非常好奇。印象中,這台麥金塔售價新台幣十幾萬元,記憶體只有512KB(1984年第一代只有128KB),配備3.5”軟碟機(沒有硬碟,用磁碟片開機),現在回頭想起來,真是不可思議。
番外篇2: 賈伯斯的遺產

學習Swift/SwiftUI程式設計的過程中,在原廠文件裡經常可看到舊有歷史痕跡,包括Swift之前的官方語言Objective-C,或SwiftUI之前的UI框架UIKit與AppKit,這些並不會被淘汰,仍有數百萬程式設計師在使用,甚至成為新框架的基礎。

最特別的一組物件,是以NS開頭,在Foundation與AppKit框架中就有不少,如NSString, NSDate, NSURL, NSAttributedString, NSBezierPath…等,是我們用到的 String, Date, URLSession, AttributedString, Path等物件的基礎,還有一個更基礎的物件 NSObject — 是Apple原廠所有40多萬個物件的老祖宗,可說是「萬物之母」。

Apple的物件名稱,大都以框架名稱縮寫當做起頭,例如第4單元用到Core Graphics框架的物件,大都以CG開頭;UIKit 框架的物件,則以UI開頭;PhotoKit 以PH開頭…等,這樣的命名規則簡單明瞭。SwiftUI是少數完全不用縮寫命名的框架(由此可見Apple給其特殊地位)。

那麼,NS又是什麼的縮寫呢?為什麼做為所有框架的基礎(Foundation),卻以NS開頭呢?

這個答案與30多年前蘋果公司創辦人賈伯斯(Steve Jobs)一段慘痛經歷有關。1985年,一直擔任董事長的賈伯斯被解除職務並踢出董事會,隨後黯然離開蘋果。創辦人竟被踢出自己所創的公司,真是情何以堪!

為什麼會這樣?董事會認為當時30歲的賈伯斯年輕氣盛、個性偏執,不但跟其他高階主管意見相左,與研發團隊也無法和諧相處,更重要的是公司經營不善,尤其1984年Macintosh與前兩年Lisa電腦的研發,不計成本地投入,造成公司鉅額虧損。

簡單地說,當時的賈伯斯有點將上市沒幾年的蘋果公司當做個人實現理想的手段(這也是很多企業創辦人常見的毛病,例如Amazon的Jeff Bezos或Tesla的Elon Musk),而不是投資人所期望的,要優先為股東創造利潤。

賈伯斯離開蘋果之後,也相當不服氣,用賣掉蘋果股票的資金,另創一家公司,叫做NeXT Inc.,目標要創造當初(1979年)他在Xerox PARC所看到(參考第一單元「人生的變數」):完美的圖形介面、人人都會操作的電腦,用新公司名稱昭告世人,這就是我的下一步,所以NeXT電腦的作業系統,不叫 NeXT OS,而是取名為 NeXTSTEP。

可惜NeXT公司的經營同樣不理想,從1985年創立到1997年被蘋果公司併購,12年期間幾乎沒有實現過盈餘。但蘋果公司也不好過,沒有賈伯斯的蘋果,就像缺乏靈魂的軀體,雖然每年都有盈餘,但在風起雲湧的電腦市場上,一直被IBM PC與Microsoft, Intel聯手打壓,市佔率不到一成。

不過,蘋果公司真正脫胎換骨的契機,就在賈伯斯重回蘋果後,決定將NeXTSTEP作業系統移植到Mac電腦,經過兩年努力,終於在1999年推出Mac OS X,成為當今macOS, iOS, iPadOS等作業系統的鼻祖。

而Objective-C, Xcode, Foundation框架,都直接繼承自NeXTSTEP,所以答案也呼之欲出,NS就代表NeXTSTEP的縮寫!

現在回顧起來,智慧手機之所以能改變全世界,除了整合各種功能的硬體(觸控螢幕、相機、WiFi、GPS、陀螺儀等)之外,真正讓這一切發生的關鍵還是軟體,軟體才能賦予手機「智慧」,而這個軟體的源頭,就是 NeXTSTEP 這套凝聚賈伯斯12年心血的作業系統,也是賈伯斯留給世人的遺產。

💡 註解
  1. 當今手機作業系統都不是從零開始研發,例如Android以開放原始碼的Linux作業系統為核心,但 iOS/macOS的核心並不是 Linux,而是跟 NeXTSTEP 一樣,是混合 Mach + BSD UNIX為核心。
  2. Mach是卡內基美隆大學(CMU)所發展的微核心(microkernel),負責最基本的記憶體管理、CPU排程控制,BSD最初由加州大學柏克萊分校(UC Berkeley)開發,負責網路、檔案系統等其他核心功能。
CUNNING
有丫,CoreData裡面很多,看了很久很久,還是有看沒有懂
雪白西丘斯
不錯,歡迎加入「數百萬程式設計師」的行列中,哈哈。
第4單元App成功上架App Store啦

經過一個月的努力,將第4單元所有範例程式,整合為一個App,還加入幾個沒有發表過的程式,並試著用 Swift Playgrounds 上架到App Store,經過重重關卡,終於成功上架了!

有興趣的讀者歡迎用 iPhone 或 iPad 下載,iOS 或 iPadOS 版本需更新到 15.2 以上。

https://apps.apple.com/tw/app/動畫與繪圖/id1642880905

雪白西丘斯
macOS 版歷經3次退件後,終於也上架了,網址同上,或在App Store搜尋「動畫與繪圖」。
Swift Playgrounds 4.2 已發布

新版 Swift Playgrounds 4.2 昨天上架到App Store了,要安裝之前,記得先升級作業系統到iPadOS 16或macOS 13。

與半年前的Swift Playgrounds 4.1相比,主要差異如下:

1. Swift 程式語言的版本從 5.6 升級到 5.7
2. 增加若干電子書內容與範例程式
3. 支援今年WWDC 2022發表的SwiftUI新功能

Swift 5.7 是程式語言的升級,上個月(2022/9)才正式發表,從上圖可以看出,Swift Playgrounds 4.2 裡面的官方範例,所有 App (.swiftpm) 與電子書(.playgroundbook)都已用 Swift 5.7 改寫。

Swift 5.7 除了增加若干新的語法與物件之外,最重要的是改寫底層程式庫,讓程式編譯與執行的速度增加不少,是一次相當重要的升級。如這個澳洲網站所說:

Swift 5.7, the latest update of Apple’s Swift programming language, is now available, with usability improvements, a smaller and faster standard library, faster build and launch times, and a new generics implementation.

Swift Playgrounds 4.2 也包含不少官方範例程式與電子書,App 除了包含原始程式碼、預覽之外,也附有中文說明,這種程式碼與文件混和的寫法是Swift Package (.swiftpm)的新功能。


其中新增的App「機器學習入門」,首次介紹在Swift Playgrounds上面開發人工智慧程式。機器學習(Machine Learning, ML)是人工智慧的基礎,可以用Apple的CoreML框架來開發相關程式,筆者正好也打算第5單元就來介紹AI程式基礎。

另一個Apple新增的範例App「佈局顯示區」,則是SwiftUI的「Layout View容器」入門課程,顯然官方將 View 譯為「顯示區」,和「視圖」比較起來並沒有特別好,不過也不算差,意思都是指螢幕一個矩形區域(學過第4單元後,應該知道就是 CGRect 所定義的範圍)。

至於今年WWDC 2022發表的SwiftUI新功能,筆者個人覺得比較重要的有3個:

1. 新的 PhotosPicker 視圖物件讓SwiftUI直接讀取相簿,不必再透過UIKit
2. 新的 Charts 框架可以做各式統計圖,是大數據、資料視覺化的利器
3. 以NavigationStack 取代 NavigationView


在進入第5單元之前,筆者會先介紹前2個SwiftUI新功能(PhotosPicker, Charts),並使用Swift Playgrounds 4.2 來實作。

下載新版Swift Playgrounds 4.2 https://www.apple.com/tw/swift/playgrounds/
文章分享
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)

今日熱門文章 網友點擊推薦!