Swift程式設計[第2單元]: SwiftUI

#19 補充說明:匿名函式(Closure)

在上一節我們提到匿名函式(Closure)本質上相當於一個未命名的函式,沒有函式名稱,不需要事先用 func 宣告,參數也沒有定義名稱與資料型態,需要臨時命名。那麼,這樣的匿名函式是做什麼的呢?

其實跟需要用到匿名函式的物件有關,以ForEach為例,ForEach 可以將n個元素的陣列,轉變為n個View,以 View的階層關係而言,ForEach 就是這n個View的父視圖。

但是子視圖有什麼外觀,並不是由 ForEach主導,而是由程式設計師撰寫的匿名函式決定,ForEach 只是負責從陣列傳遞參數給匿名函式而已,子視圖實際上是由匿名函式產出,如下圖所示:

這樣回過來看這段程式碼,{ } 所包含的「匿名函式」是不是更清楚了?
ForEach(特有種清單) { 特有種 in
單項顯示(鳥: 特有種)
.padding()
}
ForEach負責將資料從「特有種清單」陣列一個一個拿出來,透過「特有種」參數傳進入匿名函式,匿名函式則利用這個「特有種」參數,初始化「單項顯示」物件(就像按下模具的複製按鈕),產出實際的子視圖。

就像生物繁殖,父親只負責傳遞遺傳物質,母親才是孕育子代的角色,這樣看起來,若 ForEach是父視圖,那匿名函式像不像母親的角色?
#20 第7課 傑森解碼器(JSON)

在第1單元,我們發現「陣列與 for迴圈」真是程式語言中的最佳拍檔,能夠聯手處理大量複雜資料,而且只要驗證過一兩筆,同樣的程式碼就可以處理無數筆同樣類型的資料,非常符合老子「道生一,一生二,二生三,三生萬物」的哲學。

到第2單元,同樣的概念可以延伸到「陣列與ForEach」,如同前一課範例2-6b所展示的,這組拍檔可以產生無數個視圖,只要資料源源不絕的進來。

呃,問題就在資料如何源源不絕的進來?我們總不能將資料都寫在程式裡面吧!類似抖音、Instagram、Netflix以及很多一頁式網站,畫面格式是固定的,但內容可以不斷地更新,永遠都看不完,是怎麼做到的?

這就得靠「傑森」來幫忙。「傑森(JSON)」原來是 Javascript 程式語言用在動態網頁的一種資料傳輸格式,由網路後端伺服器將資料庫的結構化資料,轉換成 JSON格式,傳到使用者的瀏覽器上面,瀏覽器上面的Javascript程式再將JSON格式解構,依照欄位與設計架構呈現在畫面上,如下圖所示。利用這樣的方式,可以源源不絕地產生動態的畫面內容。


實際上的JSON格式與程式碼非常接近,用程式來轉換非常方便,而且也很容易讓人閱讀或手動編輯,如下圖。因為簡單又方便,因此成為目前網路資料交換格式的業界標準,透過 JSON格式,程式就等於有一隻手延伸到雲端資料庫上面,資料自然可以源源不絕供應進來。


更棒的是,有越來越多的網站,開放其程式介面(Open API),也是使用JSON格式來交換資料,例如 Google map, Apple Music, Facebook 等等,國內的政府資料開放平台(Open Data)也部分提供JSON格式,因此,學會在程式中使用JSON格式,未來結合網路程式,就能延伸到各大網站,享受無窮盡的資料寶藏。

筆者已將30種台灣特有種鳥類的資料,轉換成JSON格式,分享在網路上:
https://drive.google.com/drive/folders/1UT3BNDAfb24lzpPJxZ0ysVzNmNP-yQ4h

請下載 "2-7 台灣特有種鳥類 - 2021.json" 到自己設備或放在 iCloud雲端,方便匯入到 Swift Playgrounds 裡面,匯入的方式跟圖片類似,但是要選「所有類型」,如下:


確認檔案匯入之後,就可以執行以下的範例程式,執行結果會輸出到主控台(如上圖右側)。
// 2-7a 傑森解碼器
// Created by Heman, 2021/08/18
import Foundation

struct 鳥類: Codable, Identifiable {
var id: Int
var 中文名: String
var 別名: String
var 科名: String
var 英文名: String
var 圖片檔名: String
var 圖片來源: String
var 攝影者: String
}

func 傑森解碼器(_ 檔名: String) -> [鳥類]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "json") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([鳥類].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 特有種清單 = 傑森解碼器("2-7 台灣特有種鳥類 - 2021") ?? []

for 鳥種 in 特有種清單 {
print(鳥種.id, 鳥種.中文名, "\t", 鳥種.英文名, "\t", 鳥種.圖片來源)
}

這個範例程式不需要圖形介面,因此只要 import Foundation 即可,我們要從 Foundation 物件庫中取出的物件稱為 JSONDecoder (傑森解碼器),這個物件的用法如下:
let 結果 = try JSONDecoder().decode([鳥類].self, from: 資料)

JSONDecoder 必須先加空括號 () 產出一個實例,然後使用其解碼功能(方法) decode(),需要兩個參數:

1. 一個資料結構的型態,在此為[鳥類],即「鳥類」的陣列
2. 一個JSON資料格式的物件,在此為「資料」

「資料」則是從我們匯入的檔案讀取出來,讀取的方法為:
let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "json") 
let 資料 = try Data(contentsOf: 檔案)
第一行的 Bundle 也是一個物件,是指跟程式一起包裹(bundle)的檔案目錄,我們所匯入的檔案,就在主要目錄(main)裡面,然後用 url() 方法來指定檔案位置,url 原本是用來定位網址,也可以用在檔案路徑,url() 需要兩個參數,分別為檔案名稱與副檔名,這裡要仔細填入(最好用複製貼上),不能有任何小錯。

第一行只是指定檔案位置,還未讀取,真正讀取檔案的是第二行,Data() 這個物件可以讀取檔案內容,產出一個 Data 物件實例,讓傑森解碼器轉換成我們需要的陣列。

函式傑森解碼器()其他的程式碼,包括 if, do-catch, try 等等,主要是為了處理讀取失敗的情況,暫時不必深入理解。

如果讀取並解碼成功,傑森解碼器()會傳回一個 [鳥類]? 的陣列,後面的問號 ? 在第1單元曾經提過,這是個 Optional 資料類型,因為可能讀取失敗,會導致無法初始化(變數得不到值)。因此,在使用上,要多個心眼:
let 特有種清單 = 傑森解碼器("2-7 台灣特有種鳥類 - 2021") ?? []
在後面我們增加一個 ?? [] ,如果傑森解碼器()傳回 nil 的話,就將「特有種清單」指定為空陣列 [],避免沒有初始化導致程式閃退。?? 和之前學過的 ? : 有點類似,都是方便在指定句中,根據條件選擇資料值的語法。

註解
  1. JSON 全名是 "JavaScript Object Notation",意為 Javascript 物件表示法。內容是純文字組成,可以用文字編輯器手動編輯,或是用工具將Excel表格轉換為JSON檔案,但是千萬不要存成 Word 格式。
  2. 傑森解碼器 JSONDecoder().decode() 對於解碼的資料有一個要求,就是符合 Codable 規範,跟上一課 ForEach 要求 Identifiable 規範類似,Codable 規範要求資料結構必須由整數(Int)、實數(Float/Double)、字串(String)或其組成的結構(如日期Date)所組成,目前我們的「鳥類」結構是符合的,所以只須在宣告句加 Codable 關鍵字即可:
    struct 鳥類: Codable, Identifiable {
    var id: Int
    var 中文名: String
    var 別名: String
    var 科名: String
    var 英文名: String
    var 圖片檔名: String
    var 圖片來源: String
    var 攝影者: String
    }

  3. 關於 struct 鳥類: Codable, Identifiable { } 的語法,在第1課曾經提過規範(protocol)是一個較大的類別,分類層級在 struct 定義的類型之上,因此,這個宣告句的意思是「鳥類」這個類型同時屬於 Codable 類別以及 Identifiable 類別之下。

    至於規範(protocol)之間,也可以互相從屬,不過 Codable, Identifiable 與 View 彼此之間並沒有層級從屬關係,無法說誰大誰小,不過就應用範圍來說,View 類別應該是三者最大的。
#21 台灣特有種鳥類列表

有了傑森解碼器之後,我們就不需要將資料寫入程式裡面,而是從外部檔案或網路資料庫讀取資料進來,程式裡面只要定義好 struct 資料結構並確認與 JSON 內容一致即可。

現在我們終於可以列出完整的30種台灣特有種鳥類。

// 2-7b 台灣特有種鳥類
// Created by Heman, 2021/08/18
import PlaygroundSupport
import SwiftUI

struct 鳥類: Codable, Identifiable {
var id: Int
var 中文名: String
var 別名: String
var 科名: String
var 英文名: String
var 圖片檔名: String
var 圖片來源: String
var 攝影者: String
}

func 傑森解碼器(_ 檔名: String) -> [鳥類]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "json") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([鳥類].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 特有種清單 = 傑森解碼器("2-7 台灣特有種鳥類 - 2021") ?? []

for 鳥種 in 特有種清單 {
print(鳥種.id, 鳥種.中文名, "\t", 鳥種.英文名, "\t", 鳥種.圖片來源)
}

struct 相框: View {
var 檔名: String
init(_ p: String) {
檔名 = p
}
var body: some View {
if 檔名 == "" {
Image(systemName: "camera.circle")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.opacity(0.4)
} else {
Image(uiImage: UIImage(named: 檔名)!)
.resizable()
.scaledToFit()
}
}
}

struct 單項顯示: View {
var 鳥: 鳥類
var body: some View {
HStack {
VStack {
Text(鳥.中文名)
.font(.title)
.foregroundColor(.blue)
ZStack(alignment: .bottomLeading) {
相框(鳥.圖片檔名)
.frame(width: 120)
Text("\(鳥.id)")
.font(.title)
.foregroundColor(.white)
.shadow(color: .black, radius: 2, x: 0, y: 0)
}
}
VStack(alignment: .leading) {
Text("別名:" + 鳥.別名)
Text("科名:" + 鳥.科名)
Text("英文名稱:" + 鳥.英文名)
Text("圖片來源:" + 鳥.圖片來源)
Text("攝影者:" + 鳥.攝影者)
}
.font(.title2)
.lineLimit(1)
} .frame(height: 100)
}
}

struct 台灣特有種鳥類: View {
var body: some View {
VStack {
Rectangle()
.frame(height: 75)
.foregroundColor(.green)
.overlay(Text("台灣特有種鳥類").font(.largeTitle))
ScrollView {
ForEach(特有種清單) { 特有種 in
單項顯示(鳥: 特有種)
.padding()
}
}
}
}
}
PlaygroundPage.current.setLiveView(台灣特有種鳥類())

與前一課2-6b範例比較起來,我們再做最後的兩個修飾:
(1) 「單項顯示」增加一個編號,字體用反白陰影,並以 ZStack 安排在圖片左下角位置
ZStack(alignment: .bottomLeading) {
相框(鳥.圖片檔名)
.frame(width: 120)
Text("\(鳥.id)")
.font(.title)
.foregroundColor(.white)
.shadow(color: .black, radius: 2, x: 0, y: 0)

(2) 螢幕最上方增加「標題」方框
    Rectangle()
.frame(height: 75)
.foregroundColor(.green)
.overlay(Text("台灣特有種鳥類").font(.largeTitle))

最後執行的結果如下圖,是不是與一開始的設計框架十分接近?


由於 JSON 檔案("2-7 台灣特有種鳥類 - 2021.json")已經將所有鳥類圖片移除,方便讀者測試,因此會全部顯示系統圖示。

程式最後我們用 ScrollView 來控制螢幕捲動,預設為垂直捲動,可加參數 ScrollView(.horizontal) 改成水平捲動。

ScrollView 也可改為 List,同樣可以往下捲動,但外觀稍有差異,而且 List 還提供一些額外的互動功能,以後有機會再介紹。修改 List 如下影片所示。


註解
這個程式用到的資源比較多,如果遇到程式無法執行(但並非JSON解碼的問題),有可能是Swift Playgrounds本身的問題,可以按「執行我的程式碼」左邊「碼表」符號,將「啟用結果」關閉,如下圖。

預設是「啟用結果」,在「執行我的程式碼」時會追蹤每筆資料變動次數以顯示在程式頁面,這對初學者很有幫助,但因額外使用很多資源,容易造成執行速度變慢,甚至執行中斷。
#22 第8課 表情符號 Emoji

第1單元曾介紹過萬國碼 Unicode 與表情符號 Emoji,因為這兩者都是文字模式可以處理的,而且若是連續區塊(Unicode Block),很容易用 for 迴圈處理,輸出到主控台。

不過目前版本的 Emoji v13.1 完整數量超過3千個,分佈非常分散,只有部分是連續區塊,所以在第1單元(範例程式1-9c)我們只列印一小部分。還好網路上有人將 Emoji完整列表轉換成 JSON格式,現在我們有能力來處理完整的表情符號了。

Emoji v13.1 的 JSON檔案下載網址,讓我們透過傑森解碼器來操作看看:
https://unpkg.com/emoji.json/emoji.json


下載後先用「文字編輯器」觀察 emoji.json 內容(如上圖),檔案最前與最後是個 [ ],表示整個檔案是一個陣列,陣列每個元素是用 { } 包含的物件結構,如下:
{
"codes":"1F600",
"char":"😀",
"name":"grinning face",
"category":"Smileys & Emotion (face-smiling)",
"group":"Smileys & Emotion",
"subgroup":"face-smiling"
}

根據這樣的結構,我們在 Swift 中定義一個相對應的物件型態:
struct 表情符號: Codable {
var codes: String
var char: String
var name: String
var category: String
var group: String
var subgroup: String
}

這樣我們可以利用傑森解碼器(記得要在 struct 宣告加上 Codable 規範),將所有表情符號列印到主控台。

請匯入 emoji.json 到 Swift Playgrounds 裡面,然後執行以下範例程式:
// 2-8a Emoji
// Created by Heman, 2021/08/19
import Foundation

struct 表情符號: Codable {
var codes: String
var char: String
var name: String
var category: String
var group: String
var subgroup: String
}

func 傑森解碼器(_ 檔名: String) -> [表情符號]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([表情符號].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 表情符號全集 = 傑森解碼器("emoji.json") ?? []

var 編號 = 0
for 字符 in 表情符號全集 {
編號 = 編號 + 1
print(編號, 字符.char, 字符.codes, 字符.name)
}

注意這次我們在指定檔案位置的時候,沒有指定副檔名,因此在呼叫傑森解碼器()的時候,參數要傳入完整檔名(包括副檔名)。
func 傑森解碼器(_ 檔名: String) -> [表情符號]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "") {
....
}
}
let 表情符號全集 = 傑森解碼器("emoji.json") ?? []

實際上在程式裡面,副檔名的作用並不像作業系統中用來識別檔案類型,在程式中「檔名+副檔名」只是用來確認檔案位置而已,所以即使將副檔名改成 .txt 或其他也無所謂,只要匯入的檔案名稱和程式碼一致就行。

執行結果如下,一共有4590個表情符號(包含不同膚色)。


註解
  1. emoji.json檔案擁有者是 npm, Inc. (2020年被GitHub併購),授權方式採用開放原始碼常用的 MIT 授權,原始資料來自Unicode官網: https://unicode.org/Public/emoji/13.0/emoji-test.txt
  2. Unicode標準每一兩年就會更新版本,emoji.json 檔案也不見得一直都在,所以這個程式未來可能無法順利執行或需要修改。
#23 顯示表情符號 Emoji (LazyVGrid)

上一節我們順利將所有表情符號透過JSON格式讀入,並輸出到文字模式的主控台,確認資料正確。既然第2單元是學圖形介面,當然也要輸出到圖形介面,不過表情符號多達4千多個,要如何呈現比較好呢?

SwiftUI 提供許多版面配置的 View 物件(參考表2-6b),其中最適合用在很多同類型內容的,應該是 LazyVGrid 和 LazyHGrid。兩者可以將內容依照網格排列,通常手機直立適合用垂直排列的LazyVGrid,手機橫放或電腦、平板則用水平排列的 LazyHGrid,如下圖所示。


Grid 是格子排列成網狀(或稱網格或棋盤狀)的意思,那為什麼叫 Lazy (懶惰)呢?因為它不像其他包括 ScrollView/VStack/HStack/ZStack 會將所有子視圖處理完之後,才將結果顯示在螢幕,LazyVGrid/LazyHGrid 只會先排列一部分會顯示在螢幕上的子視圖,如果使用者滑動螢幕,再繼續排列下面的子視圖,雖然有點偷懶與延遲,但正適合用來處理大量的 View 物件。

我們先來看程式執行的結果,再來說明 LazyVGrid/LazyHGrid 使用的語法。按「執行我的程式碼」之前記得先關閉「啟用結果」,以避免執行中斷。
// 2-8b Emoji (LazyVGrid)
// Created by Heman, 2021/08/19
import PlaygroundSupport
import SwiftUI

struct 表情符號: Codable {
var codes: String
var char: String
var name: String
var category: String
var group: String
var subgroup: String
}

func 傑森解碼器(_ 檔名: String) -> [表情符號]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([表情符號].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 表情符號全集 = 傑森解碼器("emoji.json") ?? []
let 欄位 = [GridItem(.adaptive(minimum: 50))]

struct 顯示表情符號: View {
var body: some View {
ScrollView {
LazyVGrid(columns: 欄位) {
ForEach(表情符號全集, id: \.codes) {字符 in
Text(字符.char)
.font(.system(size: 48))
}
}
}
}
}
PlaygroundPage.current.setLiveView(顯示表情符號())


執行畫面如上圖。全部字體放大到48點,自動按照螢幕寬度,調整欄位數目,上圖顯示7欄的網格列表,可以上下捲動。

程式碼裡面,我們首先看到「顯示表情符號」的最內圈:
ForEach(表情符號全集, id: \.codes) {字符 in
Text(字符.char)
.font(.system(size: 48))
}

ForEach 的參數,除了「表情符號全集」陣列之外,還多了 id: \.codes 這個奇怪的參數,這是因為 ForEach 要求陣列必須符合 Identifiable 規範,也就是必須有 id 的欄位(參考範例程式2-6b),但是 emoji.json 的結構並非我們設計的,怎麼辦?

方法之一是手動修改 emoji.json 的內容,把 id 欄位與值加進去,但如果沒有工具,可能要大費周章,而且以後還可能透過網路抓資料,屆時欄位是無法變更的。

還好 ForEach 提供了補救之道,就是用第2個參數指定哪一個欄位可以當作 id (只要保證欄位值絕不重複),指定欄位的語法比較特殊,欄位名稱前面加「倒斜線與句號」,如 \.codes,所以第2個參數為 id: \.codes,codes 這個欄位是表情符號的 Unicode 編碼,所以絕不會重複。

如同第7課所學的,ForEach 會將「表情符號全集」陣列的4590個表情符號,轉換為4590個 Text() 子視圖,每個子視圖只顯示一個字元。

接下來將這4590個子視圖交給上一層 LazyVGrid 排版。
LazyVGrid(columns: 欄位) {
ForEach(表情符號全集, id: \.codes) {字符 in
Text(字符.char)
.font(.system(size: 48))
}
}

LazyVGrid 會根據提供的參數「欄位」排版,「欄位」的設定為一個 GridItem() 的陣列,陣列中可以有多個 GridItem() 物件:
let 欄位 = [GridItem(.adaptive(minimum: 50))]

可見 GridItem 是控制排版的關鍵,GridItem() 共有3種參數(三選一),分別是 .fixed(), .flexible(), .adaptive(),圖解如下:

在範例中,我們使用最有彈性的 .adaptive() 參數,並指定最小欄寬(minimum)為50點。接下來 LazyVGrid 就會自動排版,LazyVGrid 會在滿足最小欄寬的情況下,盡量排最多的欄位進來,善用所有的螢幕空間。

LazyVGrid() 排版後,再交給上一層 ScrollView() 來負責螢幕上下滑動,程式就完成了,簡單吧。如果是橫式排版,就改用 LazyHGrid() 配合 ScrollView(.horizontal) 讓螢幕水平(左右)捲動。

註解
  1. GridItem 的三種參數 .fixed(), .flexible(), .adaptive() 雖然有括號,但並不是函式,而是第1單元最後1課所學的列舉(enum),這三個列舉值又可包含參數,Swift 術語稱為「關聯值」(associated value),會連同列舉值一起交給 GridItem() 物件以產出實例。

  2. 像 \.codes 這樣的寫法,在 Swift 術語稱為 key path,key 代表資料結構的欄位名稱,而欄位值稱為 value,物件的每個屬性相當於 (key, value) 的組合,這也是 JSON 格式的基本結構。所以 key path 相當於 Swift 裡面指向某個物件屬性的路徑。
#24 第9課SF Symbols

在第5課「七彩蘋果」曾經簡單介紹過 SF Symbols,這是Apple官方的圖示集。在預計下個月發行新版 macOS 12 與 iOS/iPadOS 15 時,SF Symbols 也會同步更新到第3版,數量超過3000個圖示,和 Emoji 數量相當。

前一課介紹的表情符號 Emoji,其實也包含很多與圖示重複的圖案,那麼 SF Symbols 和 Emoji 有何差別呢?

由於SF Symbols 與系統字型是同一套設計準則,所以這套圖示與字型搭配上,不管是大小、對齊、顏色等方面,都會非常一致,這也是 SF Symbols 和表情符號 Emoji 最不一樣的地方,Emoji 色彩是不能改的,在某些情況下會顯得花俏或突兀。

而SF Symbols 每個圖示都像字體一樣,不但有大小,還有明暗、粗細、顏色等可搭配字體的屬性,如下圖。

SF Symbols 使用上也非常方便,只要知道圖示名稱(systemName),就可以用 Image 物件顯示出來,如果能列出所有的 systemName 名稱就太好了。

筆者透過官方的 SF Symbols App (v3 build 56),導出所有圖示名稱,再手動轉換成 JSON格式,方便我們在程式中讀取。以下網址可下載 "2-9 sfsymbols3.json",請匯入到 Swift Playgrounds 中,以便執行範例程式,執行前仍要記得關閉「啟用結果」。
https://drive.google.com/file/d/1NZaC8gIU11Aj27O5P5aXQgAiH0yLFhvd/view?usp=sharing

JSON 檔案格式如上圖,只有一欄字串值,表示 systemName,因此我們使用「傑森解碼器」時,傳回的型態為字串陣列 [String]
// 2-9a SF Symbols 3 (LazyVGrid)
// Created by Heman, 2021/08/21
import PlaygroundSupport
import SwiftUI

func 傑森解碼器(_ 檔名: String) -> [String]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([String].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 系統圖示集 = 傑森解碼器("2-9 sfsymbols3.json") ?? []
let 欄位 = [GridItem(.adaptive(minimum: 40))]
var 編號 = 0
for 名稱 in 系統圖示集 {
編號 = 編號 + 1
print(編號, 名稱)
}

struct 顯示系統圖示: View {
var body: some View {
VStack {
Rectangle()
.frame(height: 60)
.foregroundColor(.orange)
.overlay(Label("[雪]Swift程式設計", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.white))
ScrollView {
LazyVGrid(columns: 欄位) {
ForEach(系統圖示集, id: \.self) { 名稱 in
Image(systemName: 名稱)
.font(.largeTitle)
.foregroundColor(.blue)
}
}
}
}
}
}
PlaygroundPage.current.setLiveView(顯示系統圖示())

仿照前一課的 ScrollView + LazyVGrid + ForEach 組合,我們可以很容易表列出所有的系統圖示,其中只有一個小差異,就是 id 欄位:
ForEach(系統圖示集, id: \.self) { 名稱 in
Image(systemName: 名稱)
.font(.largeTitle)
.foregroundColor(.blue)
}

ForEach 要求符合 Identifiable 規範的資料結構,前一課我們還能選擇可當作識別的欄位,但這次只有一欄 String (圖示名稱),連欄位名稱都沒有,怎麼提供當作 id 的 "key path"?

還好 Swift 所有的類型都是物件類型,包括 String 或 Int,而所有物件類型都有一個隱藏版的欄位(即物件屬性),稱為 self,這個 self 屬性代表物件本身,如果是物件類型,如 [String].self,指的是字串陣列 [String] 類型自身,如果是物件實例,如 Rectangle().self,self 指的是物件實例本身。

所以此例程式中, id: \.self 意思就是用字串值本身(會經過雜湊"hash"處理)當作 id 辨識碼,因為 SF Symbols 名稱都不會重複。

執行結果如下圖,最上面我們增加了一個標題,剛好有個系統圖示是 Swift 程式語言的商標,圖示名稱為 "swift"。標題用的 Label 在下一節介紹。

從控制台的文字輸出顯示,這個版本的 SF Symbols 一共是3208個。

筆者目前用macOS 11.5,因此有不少圖示位置是空白的,等下個月作業系統升級macOS 12之後,這些圖示應該就會補齊了。

註解
  1. Swift 名詞是雨燕,當形容詞是快速的,Swift 商標圖案是一隻雨燕,這是Apple公司的註冊商標。
  2. 所以Swift Playgrounds是雨燕遊樂場的意思。
  3. SF Symbols所有權屬於Apple公司,但免費提供給程式設計師使用(當然必須用於開發Apple產品的軟體)。
  4. 共享檔案 "2-9 sfsymbols3.json" 已更新為SF Symbols v3.1 (63),共計3,294個圖示。
#25 多才多藝的 List

在SwiftUI 眾多的排版物件(表2-6b)中,List 雖然只能單欄垂直表列,但卻是最多才多藝的,怎麼說呢?因為 List 初始化方式多達30種,也就是能接受30種參數組合,是所有排版物件中最多的,List 會做的事比別的物件多,包括:

* 單欄表列
* 分組表列
* 分層表列
* 互動選單(單選或複選)
* 動態內容(動態增刪項目、更新表列等)

更特別的是,List 可以配合 ForEach,也可以自己處理陣列(擔任父視圖角色,參考 #19 補充說明),所以是個多才多藝的父親。

下面範例我們先學 List 單欄表列的基本功能。
// 2-9b SF Symbols 3 (List)
// Created by Heman, 2021/08/21
import PlaygroundSupport
import SwiftUI

func 傑森解碼器(_ 檔名: String) -> [String]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([String].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 系統圖示集 = 傑森解碼器("2-9 sfsymbols3.json") ?? []
struct 顯示系統圖示: View {
var body: some View {
VStack {
Rectangle()
.frame(height: 60)
.foregroundColor(.orange)
.overlay(Label("[雪]Swift程式設計", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.white))
List(系統圖示集, id: \.self) { 名稱 in
Label(名稱, systemImage: 名稱)
.font(.title)
.foregroundColor(.blue)
.lineLimit(1)
}
}
}
}
PlaygroundPage.current.setLiveView(顯示系統圖示())


如同上一節用傑森解碼器讀入 SF Symbols 列表,放入「系統圖示集」陣列中,再交由 List 直接處理陣列,這時的 List用法和 ForEach類似,參數為陣列與 id: key path:

List(系統圖示集, id: \.self) { 名稱 in
Label(名稱, systemImage: 名稱)
.font(.title)
.foregroundColor(.blue)
.lineLimit(1)
}

List 會負責參數的傳遞,將系統圖示名稱一個一個傳給匿名函式(Closure),而在匿名函式中,對傳入的每個系統圖示,我們用 Label 來表現其外觀。Label 的用法非常簡單:
Label("文字標籤", View物件)

其實 Label 就是對某個View物件加上文字標籤,這有什麼特別?

一方面是這樣的應用很多,例如選單中,用圖示與選項文字搭配,通訊軟體中好友頭像與姓名搭配,餐廳菜單圖片與品名搭配...等,非常多應用場合,另一方面,Label 也提供國際化與無障礙功能,文字標籤可改成各國文字或語音播放。

在範例中,我們文字標籤直接代入系統圖示名稱,而在View物件的部分,指定系統圖示的參數是 systemImage: "圖示名稱",相當於 Image(systemName:) 的縮寫。

此例 Label 只包含文字與系統圖示,剛好都可如 Text() 一樣指定字體大小 .font()、顏色 .foregroundColor()、行數限制 .lineLimit() 等等。

執行結果如下,Label 的外觀先顯示圖案(View 物件),再顯示文字標籤,和參數順序相反:
#26 有大學或高中生需要現場指導嗎?

第2單元已接近尾聲,不知道大家是否都跟上了?

雖然筆者目標是盡量以淺顯文字解釋程式邏輯和語法,讓高中程度的年輕朋友能夠自學、動手操作與練習,但從我兩個女兒的短暫教學經驗,相信大部分還是會遇到難題,出現跨不過去的坎。

筆者目前每週兩天在善科教育基金會擔任志工,基金會的宗旨是普及科學教育,做法是補充(而非取代或重複)現有教育體系不足之處。

如果真有年輕朋友想要學好Swift程式設計,對於創作App有興趣,但遇到問題無法自己解決,需要人指導,或許可以商借善科教育基金會的場地,筆者提供現場指導(不教學,以解決問題或交流意見為主),但對象僅限大學或高中在學學生。

如果有需要的話,請以email與筆者聯絡,善科教育基金會的地址在捷運東門站,可參考善科官網 https://www.sancode.org.tw/

Please mailto: heman@sancode.org.tw
#27 第10課 手勢控制(淡出淡入)

手勢操作是智慧型手機與平板電腦易學易用的最大功臣,但直到最近十幾年才普及,原因一方面是觸控螢幕硬體本身比較貴,另一方面則受限於作業系統的發展,作業系統的輸入方式從早期的鍵盤(文字模式)、滑鼠(圖形模式),最後才演進到手勢和語音。

在第6課[表2-6e]的手勢操作元件中,有兩個手勢可以直接利用View修飾語來實現,就是點按(TapGuesture)和長壓(LongPressGesture),是比較容易實現的手勢操作,點按就相當於滑鼠的點擊(click),我們首先學習用點按手勢來切換兩張圖片。
- 手勢操作元件 View修飾語 說明 第2單元
學習地圖
1 Gesture .gesture() 手勢規範(protocol) -
2 TapGesture .onTapGesture() 點按 第10課
3 LongPressGesture .onLongPressGesture() 長壓 -
4 DragGesture - 拖曳 -
5 MagnificationGesture - 放大 -
6 RotationGesture - 旋轉 -
7 SequenceGesture - 依序組合 -

初步的想法,是利用 ZStack 重疊兩張圖片,上面那張一開始透明度為0(完全透明,看不到),手勢點按之後,透明度從0慢慢變成1.0(完全不透明,可遮住底圖),這種效果稱為淡入(Fade-in)。若再點一次,透明度從1.0慢慢變回0,稱為淡出(Fade-out)。
// 2-10a Fade-in Fade-out
// Created by Heman, 2021/08/25
import PlaygroundSupport
import SwiftUI

struct 相框: View {
var 檔名: String
init(_ p: String) { 檔名 = p }
var body: some View {
Image(uiImage: UIImage(named: 檔名)!)
.resizable()
.scaledToFit()
.cornerRadius(20)
}
}

struct 淡出淡入: View {
@State var 淡出 = true
var body: some View {
ZStack {
相框("unsplash02.jpg")
相框("IMG_8935.jpg")
.opacity(淡出 ? 0.0 : 1.0)
.animation(.easeInOut(duration: 1.2))
}
.onTapGesture {
淡出.toggle()
}
}
}

PlaygroundPage.current.setLiveView(淡出淡入())

執行結果是動態的,擷取兩張圖片重疊的瞬間:


主要呈現淡出淡入效果的程式碼如下:
struct 淡出淡入: View {
@State var 淡出 = true
var body: some View {
ZStack {
相框("unsplash02.jpg")
相框("IMG_8935.jpg")
.opacity(淡出 ? 0.0 : 1.0)
.animation(.easeInOut(duration: 1.2))
}
.onTapGesture {
淡出.toggle()
}
}
}

這段程式碼有5個新語句:

① @State var 淡出 = true
② .opacity(淡出 ? 0.0 : 1.0)
③ .animation(.easeInOut(duration: 1.2))
④ .onTapGesture
⑤ 淡出.toggle()

首先設定一個布爾變數「淡出」,當「淡出」為 true時,上層圖片淡出不顯示,為 false 時淡入顯示。

這裡的新語法是 var 前面加一個 @State,這是讓整個畫面能夠動態更新的關鍵,有加跟沒加執行效果完全不一樣,讀者可以拿掉 @State 試試看。

@ 符號念作 at,在Swift術語稱為 Property Wrapper (屬性包裝),意思就是將物件的變數(屬性)再加一層特殊屬性,就像物品的包裝紙一樣,有其特殊意義。

State 字意為狀態,在這裡是讓作業系統監控這個變數的狀態,如果有變化,就更新 View 畫面,整個主體(body)程式碼重新執行一遍,所以只要改變「淡出」變數值,就能讓畫面動起來。

如何改變「淡出」變數值呢?就在整個 ZStack 加上一個修飾語:
        .onTapGesture {
淡出.toggle()
}

表示在整個 ZStack View 範圍內,如果收到點按手勢(TapGesture)的訊號,就執行 { } 段落裡面的程式碼,也就是呼叫「淡出」物件的方法 toggle(),這是布爾變數(也是物件)專屬的方法,會切換 true/false 值,true變 false,false 變true。

當「淡出」值變換後,因為 @State 的關係,整個View會重新計算,這時候上層圖片的修飾語,就會有不同的情況:
            相框("IMG_8935.jpg")
.opacity(淡出 ? 0.0 : 1.0)
.animation(.easeInOut(duration: 1.2))

隨著「淡出」從true變成 false,opacity()透明度會從0.0變成1.0。然後加上一個動態效果 animation(),負責呈現淡出淡入效果,變換的時間是1.2秒。

animation() 也是一個 View 修飾語,用在 View 不同狀態之間的變換,目前共有8種變換效果,本課只學最常用的 easeInOut(),其他有機會到後面單元再介紹。

1. easeIn()
2. easeOut()
3. easeInOut()
4. linear()
5. sping()
6. interactiveSpring()
7. interpolatingSping()
8. timingCurve()

執行結果的影片如下:


最後,ZStack 的部分還可以加一層文字,提示使用者可以點任何地方以變換圖形。以下範例請讀者自行測試:
ZStack(alignment: .top) {
相框("unsplash02.jpg")
相框("IMG_8935.jpg")
.opacity(淡出 ? 0.0 : 1.0)
.animation(.easeInOut(duration: 1.2))
Text("請點任何地方")
.font(.title2)
.foregroundColor(.orange)
.shadow(color: .white, radius: 5, x: 0, y: 0)
}
#28 SF Symbols 列表:手勢點選

前一節我們學習 TapGesture,是最常用的手勢操作之一,Tap是輕敲、輕拍、輕觸的意思,而 Gesture 是手勢。

TapGesture 可以用在 View 的點選,只要在 View 的顯示範圍內點一下,就能產生回饋來互動,至於反應動作要做什麼,就看程式設計師如何設計。

下面範例我們用第9課的 SF Symbols 系統圖示列表,來當作點選標的,每個圖示都可以單獨點選,點選後圖示會變為橙色。
// 2-10b SF Symbols 3 (TapGesture)
// Revised from 2-9a by Heman, 2021/08/25
import PlaygroundSupport
import SwiftUI

func 傑森解碼器(_ 檔名: String) -> [String]? {
if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "") {
do {
let 資料 = try Data(contentsOf: 檔案)
let 結果 = try JSONDecoder().decode([String].self, from: 資料)
return 結果
} catch {
print("error:\(error)")
}
}
return nil
}

let 系統圖示集 = 傑森解碼器("2-9 sfsymbols3.json") ?? []
let 欄位 = [GridItem(.adaptive(minimum: 50))]

struct 圖示: View {
@State var 點選 = false
var 圖示名稱: String
init(_ p: String) { 圖示名稱 = p }
var body: some View {
Image(systemName: 圖示名稱)
.resizable()
.scaledToFit()
.foregroundColor(點選 ? .orange : .primary)
.offset(y: 點選 ? -2 : 0)
.frame(width: 50)
.onTapGesture {
點選.toggle()
}
}
}

struct 顯示系統圖示: View {
var body: some View {
VStack {
Rectangle()
.frame(height: 60)
.foregroundColor(.orange)
.overlay(Label("[雪]Swift程式設計", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.white))
ScrollView {
LazyVGrid(columns: 欄位) {
ForEach(系統圖示集, id: \.self) { 名稱 in
圖示(名稱)
}
}
}
}
}
}
PlaygroundPage.current.setLiveView(顯示系統圖示())

主要修改的部分,就是為個別圖示增加手勢的修飾語:
struct 圖示: View {
@State var 點選 = false
var 圖示名稱: String
init(_ p: String) { 圖示名稱 = p }
var body: some View {
Image(systemName: 圖示名稱)
.resizable()
.scaledToFit()
.foregroundColor(點選 ? .orange : .primary)
.offset(y: 點選 ? -2 : 0)
.frame(width: 50)
.onTapGesture {
點選.toggle()
}
}
}

對每個圖示物件,增加一個 @State 狀態變數「點選」,若為 true 則顯示橙色,若為 false 則恢復黑白。
            .foregroundColor(點選 ? .orange : .primary) 

這裡的 .primary 是系統預設前景顏色,會自動切換暗黑(深色)模式與白天(淺色)模式:
- 深色模式(Dark mode) 淺色模式
.primary (前景) 白色 黑色
.secondary (背景) 黑色 白色

另外我們增加一個修飾語
            .offset(y: 點選 ? -2 : 0)

點選時,圖示往上移動2點,再點選一次則恢復原來位置,這樣的小動作可提升互動的體驗。未來若我們學會播放音效,這時候可再播放一個「答」按鈕聲,互動性更佳。

以下是在暗黑模式下的執行結果:


暗黑模式的執行影片如下,注意點選時的小幅度上下移動。


註解
  1. Tap當名詞是水龍頭,Tap Water 是自來水。
  2. 同樣的,2-9b List 列表也可以改成用 TapGesture 點選,請讀者動手試試看。
文章分享
評分
評分
複製連結

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