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

#10 中文斷句 修正版

要解決上一節遇到的搜尋問題,我們需要深入了解Swift語言的陣列(Array)與字串(String)構造,特別是其「索引(Index)」的部分。

其實上節範例4-3b有一小段程式碼沒有解釋,就是與索引有關,在程式最後一段:
// 範例 4-3b 最後一段
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}

這段的作用是讓索引在陣列中循環,如果索引已到達末尾,就返回開頭重新開始,否則就指向下一個索引。範例4-3a也有一段程式碼有同樣目的,但寫法更簡潔:
//範例 4-3a 最後一段
行次 = (行次 + 1) % 行數

4-3a的「行次」相當於4-3b的「索引」,利用除以「行數」所得餘數來控制在陣列中循環,這個方法簡單又方便,為什麼不用呢?這是因為Swift陣列的索引,並不一定總是連續整數,範例4-3b的方法雖然稍麻煩,卻100%通用。以下是進一步說明。

多數情況下,陣列索引的確是以 0 開始的連續整數,以範例4-3b為例,斷句分解後的「分解結果」就是一個這樣的字串陣列,如下圖:


這種情況下,索引是連續整數0, 1, 2, ...,用 索引 = (索引 + 1) % 分解結果.count 恰好可以讓索引在0到122之間循環。但Swift允許陣列的索引不一定是「連續」整數,也不一定從0開始,這個用餘數的方法就無效了,怎麼辦?

Swift所有陣列都會有一個屬性 startIndex 表示起始索引(在上圖為0),還有一個屬性 endIndex 表示索引結束,要注意的是,endIndex 並不是最後一個索引,而是「最後一個索引的下一個值」。例如上圖中,最後一個索引是122,整個陣列的索引範圍是0到122,但是 endIndex 卻是123!

這是Swift語言非常特殊的設計(說不上好壞),也是初學者最容易犯的致命錯誤之一,如果不小心用 endIndex 當作索引取值,會導致 “Index out of range" (索引超出範圍)的錯誤而導致App閃退!

所以,整個陣列的索引範圍,要寫成 startIndex ..< endIndex,也就是說:
分解結果.indices == [分解結果.startIndex ..< 分解結果.endIndex]

在Swift裡面,1...122 與 1..<123 是兩種不同的範圍類型,雖然都代表0到122的連續整數,前者(1...122)是我們過去常用的,類型名稱為 ClosedRange (封閉區間/封閉範圍),後者(1..<123)類型名稱才是 Range,表示以Swift來看,後者應該比較常用。

我們用的字串搜尋方法 .range(of:) 傳回的範圍類型是 Range,而不是 ClosedRange。所以在上節範例,用「分解結果」的每一句去本文的「顯示內容」中搜尋時,會得到該句字串的 Range,示意圖如下:


Range物件有兩個屬性,lowerBound與upperBound分別指定範圍的下限與上限,這裡的上下限是指字串索引的上下限,下限相當於範圍內(子字串)的起始索引,而上限upperBound則是範圍(子字串)的結束索引。

為什麼字串也有索引?字串也是陣列嗎?是的,在Swift內部設計,字串(String)是一個特別的陣列,陣列元素是字串個別的字元(Character)。

最特別的是,Swift 的字串索引並不是連續整數!所以我們只能透過 startIndex, endIndex 以及相關的索引函式來操作,如下圖:


要找到下一個索引,可以用字串物件方法 .index(after:),例如上圖 i = 本文.index(after: i) 會將 i 指定為下一個索引值。

我們可以用一段小程式來測試一下字串的索引值:
// Tested by Heman, 2022/04/04
import Foundation

let 西遊記第一回 = """
第一回 靈根育孕源流出 心性修持大道生

  詩曰:
    混沌未分天地亂,茫茫渺渺無人見。
    自從盤古破鴻濛,開闢從茲清濁辨。
    覆載群生仰至仁,發明萬物皆成善。
    欲知造化會元功,須看西遊釋厄傳。
  蓋聞天地之數,有十二萬九千六百歲為一元。將一元分為十二會,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每會該一萬八百歲。且就一日而論:子時得陽氣,而丑則雞鳴﹔寅不通光,而卯則日出﹔辰時食後,而巳則挨排﹔日午天中,而未則西蹉﹔申時晡,而日落酉,戌黃昏,而人定亥。譬於大數,若到戌會之終,則天地昏曚而萬物否矣。再去五千四百歲,交亥會之初,則當黑暗,而兩間人物俱無矣,故曰混沌。又五千四百歲,亥會將終,貞下起元,近子之會,而復逐漸開明。邵康節曰:「冬至子之半,天心無改移。一陽初動處,萬物未生時。」到此,天始有根。再五千四百歲,正當子會,輕清上騰,有日,有月,有星,有辰。日、月、星、辰,謂之四象。故曰,天開於子。又經五千四百歲,子會將終,近丑之會,而逐漸堅實。《易》曰:「大哉乾元!至哉坤元!萬物資生,乃順承天。」至此,地始凝結。再五千四百歲,正當丑會,重濁下凝,有水,有火,有山,有石,有土。水、火、山、石、土,謂之五形。故曰,地闢於丑。又經五千四百歲,丑會終而寅會之初,發生萬物。曆曰:「天氣下降,地氣上升﹔天地交合,群物皆生。」至此,天清地爽,陰陽交合。再五千四百歲,正當寅會,生人,生獸,生禽,正謂天地人,三才定位。故曰,人生於寅。
"""
print(西遊記第一回.count)
print(西遊記第一回.startIndex)
print(西遊記第一回.endIndex)
let 本文 = 西遊記第一回
var i = 本文.startIndex
while i < 本文.endIndex {
print(本文[i], i)
i = 本文.index(after: i)
}

執行後控制台的輸出如下,可以看出「字串索引」並不是連續的整數:
642                         //count: 一共642個「字元」,含中文字、標點符號、空格、換行
Index(_rawBits: 1) //startIndex: 第一個索引值
Index(_rawBits: 124780545) //endIndex: 最後一個索引值後面的結尾
第 Index(_rawBits: 1)
一 Index(_rawBits: 197377)
回 Index(_rawBits: 393985)
Index(_rawBits: 590081)
Index(_rawBits: 655617)
Index(_rawBits: 721153)
Index(_rawBits: 786689)
靈 Index(_rawBits: 852737)
根 Index(_rawBits: 1049345)
....


但字串的索引值到底是什麼?這裡的 _rawBits 又代表什麼?這個我們不用管,因為Apple並沒有公開內部細節(名稱若是以底線”_”開頭,如 _rawBits,就表示內部使用,細節不公開),我們只需透過索引的操作函式去使用即可。

了解字串索引之後,我們終於可以解決上節所遇到的問題,修正後的程式碼如下,只是多增加一個狀態變數「搜尋起點」與幾行程式碼:
// 4-3c 中文斷句 修正版 Substring
// Revised by Heman, 2022/04/03
import PlaygroundSupport
import SwiftUI

struct 小說: View {
var 本文: String
init(_ 字串參數: String) { 本文 = 字串參數 }
@State var 顯示內容 = AttributedString("")
@State var 分解結果: [String] = []
@State var 索引 = 0
@State var 搜尋起點 = AttributedString("").startIndex
let 定時器 = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()
var body: some View {
Text(顯示內容)
.font(.system(.title3))
.onAppear {
let 初步斷句 = 本文.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))
分解結果 = 初步斷句.filter { 句子 in
句子 != ""
}
索引 = 分解結果.startIndex
// 搜尋起點 = AttributedString(本文).startIndex
}
.onReceive(定時器) { _ in
顯示內容 = AttributedString(本文)
let 搜尋範圍 = 搜尋起點 ..< 顯示內容.endIndex
if let 範圍 = 顯示內容[搜尋範圍].range(of: 分解結果[索引]) {
顯示內容[範圍].foregroundColor = .white
顯示內容[範圍].backgroundColor = .cyan
print(範圍.lowerBound, 範圍.upperBound)
搜尋起點 = 範圍.upperBound
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
搜尋起點 = 顯示內容.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}
}
}
}

let 西遊記第一回 = """
第一回 靈根育孕源流出 心性修持大道生

  詩曰:
    混沌未分天地亂,茫茫渺渺無人見。
    自從盤古破鴻濛,開闢從茲清濁辨。
    覆載群生仰至仁,發明萬物皆成善。
    欲知造化會元功,須看西遊釋厄傳。
  蓋聞天地之數,有十二萬九千六百歲為一元。將一元分為十二會,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每會該一萬八百歲。且就一日而論:子時得陽氣,而丑則雞鳴﹔寅不通光,而卯則日出﹔辰時食後,而巳則挨排﹔日午天中,而未則西蹉﹔申時晡,而日落酉,戌黃昏,而人定亥。譬於大數,若到戌會之終,則天地昏曚而萬物否矣。再去五千四百歲,交亥會之初,則當黑暗,而兩間人物俱無矣,故曰混沌。又五千四百歲,亥會將終,貞下起元,近子之會,而復逐漸開明。邵康節曰:「冬至子之半,天心無改移。一陽初動處,萬物未生時。」到此,天始有根。再五千四百歲,正當子會,輕清上騰,有日,有月,有星,有辰。日、月、星、辰,謂之四象。故曰,天開於子。又經五千四百歲,子會將終,近丑之會,而逐漸堅實。《易》曰:「大哉乾元!至哉坤元!萬物資生,乃順承天。」至此,地始凝結。再五千四百歲,正當丑會,重濁下凝,有水,有火,有山,有石,有土。水、火、山、石、土,謂之五形。故曰,地闢於丑。又經五千四百歲,丑會終而寅會之初,發生萬物。曆曰:「天氣下降,地氣上升﹔天地交合,群物皆生。」至此,天清地爽,陰陽交合。再五千四百歲,正當寅會,生人,生獸,生禽,正謂天地人,三才定位。故曰,人生於寅。
"""

PlaygroundPage.current.setLiveView(小說(西遊記第一回))


解決問題的關鍵,就在於索引範圍的操作:
let 搜尋範圍 = 搜尋起點 ..< 顯示內容.endIndex
if let 範圍 = 顯示內容[搜尋範圍].range(of: 分解結果[索引]) {
搜尋起點 = 範圍.upperBound
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
搜尋起點 = 顯示內容.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}

每次搜尋到子字串範圍後,就將「搜尋起點」指定為子字串範圍的upperBound(尾端之後),再將「搜尋範圍」設為「從 upperBound 到本文的 endIndex」,這樣前面搜尋過的子字串,就不會再被搜尋啦。

執行過程影片如下:


💡 註解
  1. 為什麼Swift陣列索引不一定是連續整數?這是一種通用化的做法,只保持陣列最基本的特性,也就是(1)元素由相同類型組成 (2)元素之間有前後次序關係,這樣可以保持最大彈性。其實陣列還有第3個特性,就是元素可以隨機存取(random access),比較技術性,不懂沒關係。
  2. 猜猜看空字串與空陣列的 startIndex, endIndex 是何值?可試試下面的小程式
    //Tested by Heman, 2022/04/05
    import Foundation

    let a = ""
    print(a.startIndex, a.endIndex)
    print(a.first)

    let b: [String] = []
    print(b.startIndex, b.endIndex)
    print(b.first)

  3. 筆者常會記錯,將陣列第一個索引 startIndex 記為 firstIndex。陣列屬性的確有first,是指第一個元素,如測試範例的 a.first, b.first。
  4. 為什麼 print() 抓空字串的 endIndex 的元素(a.first)不會發生 "Index out of range" 的錯誤?
#11 4-3d “Index out of range” bug 修正

如果有同學測試過上一節最後註解裡面的小程式,就可能會發現範例4-3b與4-3c有個bug。如果我們將程式最後一行:
PlaygroundPage.current.setLiveView(小說(西遊記第一回))

改成:
PlaygroundPage.current.setLiveView(小說(""))

執行程式就會出現上節提到的 “Index out of range” 的錯誤,表示我們寫的「小說」視圖不完善:


在4-3b我們為了避免使用全域變數,改由參數傳入「小說」,讓「小說」這個視圖物件比較完整、獨立,這時對參數的「邊界條件」就必須考慮清楚,字串類型的參數而言,「空字串」是很重要的邊界條件,一定要測試。

修正後完整程式如下,主要修改 onAppear 匿名函式的內容,增加一個 if 條件句:
// 4-3d 中文斷句 修正版 Substring
// Revised by Heman, 2022/04/03
// Updated by Heman, 2022/04/08 修正bug
import PlaygroundSupport
import SwiftUI

struct 小說: View {
var 本文: String
init(_ 字串參數: String) { 本文 = 字串參數 }
@State var 顯示內容 = AttributedString("")
@State var 分解結果: [String] = []
@State var 索引 = 0
@State var 搜尋起點 = AttributedString("").startIndex
let 定時器 = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()
var body: some View {
Text(顯示內容)
.font(.system(.title3))
.onAppear {
if 本文 == "" {
顯示內容 = AttributedString("參數請勿提供「空字串」")
定時器.upstream.connect().cancel()
} else {
let 初步斷句 = 本文.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))
分解結果 = 初步斷句.filter { 句子 in
句子 != ""
}
索引 = 分解結果.startIndex
搜尋起點 = AttributedString(本文).startIndex
print("「分解結果」一共\(分解結果.count)句")
print("startIndex = \(分解結果.startIndex)")
print("endIndex = \(分解結果.endIndex)")
}
}
.onReceive(定時器) { _ in
顯示內容 = AttributedString(本文)
let 搜尋範圍 = 搜尋起點 ..< 顯示內容.endIndex
if let 範圍 = 顯示內容[搜尋範圍].range(of: 分解結果[索引]) {
顯示內容[範圍].foregroundColor = .white
顯示內容[範圍].backgroundColor = .cyan
print("句子範圍(range):\(範圍)")
print("lowerBound = \(範圍.lowerBound)")
print("upperBound = \(範圍.upperBound)")
搜尋起點 = 範圍.upperBound
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
搜尋起點 = 顯示內容.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}
}
}
}

let 西遊記第一回 = """
第一回 靈根育孕源流出 心性修持大道生

  詩曰:
    混沌未分天地亂,茫茫渺渺無人見。
    自從盤古破鴻濛,開闢從茲清濁辨。
    覆載群生仰至仁,發明萬物皆成善。
    欲知造化會元功,須看西遊釋厄傳。
  蓋聞天地之數,有十二萬九千六百歲為一元。將一元分為十二會,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每會該一萬八百歲。且就一日而論:子時得陽氣,而丑則雞鳴﹔寅不通光,而卯則日出﹔辰時食後,而巳則挨排﹔日午天中,而未則西蹉﹔申時晡,而日落酉,戌黃昏,而人定亥。譬於大數,若到戌會之終,則天地昏曚而萬物否矣。再去五千四百歲,交亥會之初,則當黑暗,而兩間人物俱無矣,故曰混沌。又五千四百歲,亥會將終,貞下起元,近子之會,而復逐漸開明。邵康節曰:「冬至子之半,天心無改移。一陽初動處,萬物未生時。」到此,天始有根。再五千四百歲,正當子會,輕清上騰,有日,有月,有星,有辰。日、月、星、辰,謂之四象。故曰,天開於子。又經五千四百歲,子會將終,近丑之會,而逐漸堅實。《易》曰:「大哉乾元!至哉坤元!萬物資生,乃順承天。」至此,地始凝結。再五千四百歲,正當丑會,重濁下凝,有水,有火,有山,有石,有土。水、火、山、石、土,謂之五形。故曰,地闢於丑。又經五千四百歲,丑會終而寅會之初,發生萬物。曆曰:「天氣下降,地氣上升﹔天地交合,群物皆生。」至此,天清地爽,陰陽交合。再五千四百歲,正當寅會,生人,生獸,生禽,正謂天地人,三才定位。故曰,人生於寅。
"""

// PlaygroundPage.current.setLiveView(小說(西遊記第一回))
PlaygroundPage.current.setLiveView(小說("")) // bug fixed.


其中 if 條件句如果發現是空字串,則顯示錯誤訊息,並將定時器關閉:
if 本文 == "" {
顯示內容 = AttributedString("參數請勿提供「空字串」")
定時器.upstream.connect().cancel()
}

第3單元第2課提過,定時器Timer是用 Publisher-Subscriber (發布-訂閱)模式溝通,一般情況下,總是由Publisher傳遞訊息給Subscriber,而在此,「定時器.upstream」會建立一個反向通道, 由Subscriber 反向傳訊息給 Publisher,連接後,傳遞「取消(cancel)」訂閱的訊息給定時器,之後 onReceive() 就不會再收到定時器的事件了。
#12 第4課 時間軸視圖 TimelineView

前面幾課所學的「動畫效果」,一則是利用 Animation 物件,根據時間曲線用內插法計算兩個視圖中間的過程,一則是利用「定時器」Timer 物件提供時間,按時觸發視圖本身狀態的變化。歸納起來,動畫的關鍵要素離不開「時間」,可以說「時間」驅動了動畫的效果。

本課將介紹SwiftUI新的時間軸視圖 TimelineView,是第3種製作動畫效果的方式,從名稱就可看出,這個視圖物件也跟時間有關。

TimelineView 是一種視圖容器(View Container),與 VStack, HStack, List, NavigationView...等排版功能的容器類似,可用來容納別的視圖,在第2單元第4課(2-4c)提過,容器內的視圖可稱為子視圖。

如果說,排版功能的容器是用來控制子視圖的空間排列,那麼,時間軸視圖就是控制子視圖的「時間排程」。我們先用一個簡單的範例程式,來看看時間軸視圖能做什麼:
// 4-4a TimelineView (時間軸視圖)
// Created by Heman, 2022/04/07
import PlaygroundSupport
import SwiftUI

struct 週期性排程: View {
var body: some View {
TimelineView(.periodic(from: Date(), by: 1.0)) { 時間參數 in
Text("\(時間參數.date)")
.font(.title2)
.padding()
}
}
}

struct 連續時間排程: View {
var body: some View {
TimelineView(.animation) { 時間參數 in
let 本地曆法 = Calendar.current
let 秒數 = 本地曆法.component(.second, from: 時間參數.date)
let 奈秒 = 本地曆法.component(.nanosecond, from: 時間參數.date)
Text("\(時間參數.date)\n\(秒數):\(奈秒)")
.font(.title2)
.onAppear {
print(時間參數.date)
print(時間參數.cadence)
}
}
}
}

struct 時間軸視圖: View {
var body: some View {
週期性排程()
連續時間排程()
}
}

PlaygroundPage.current.setLiveView(時間軸視圖())


時間軸的語法和其他視圖類似,需要一個參數並尾隨一個匿名函式,參數需提供「時間排程(Schedule)」,時間軸視圖會在時間排程的每一時刻,將「時間參數」帶入匿名函式中。

範例第一部分設定為「週期性排程」(periodic),從現在時刻Date()開始,每1.0秒鐘更新一次容器內的子視圖:
TimelineView(.periodic(from: Date(), by: 1.0)) { 時間參數 in
Text("\(時間參數.date)")
.font(.title2)
.padding()
}

參數時間排程(Schedule)目前有以下5種預設屬性或方法,本節範例使用前兩個最常用排程,以初步熟悉時間軸視圖的物件特性:

1. periodic(from: 起始時間, by: 週期間隔)
2. animation
3. animation(minimumInterval: 最短間隔, paused: 可否暫停)
4. everyMinute
5. explicit(時間序列)

時間軸視圖的週期性排程(periodic)與上一課用的定時器(Timer)效果相當,不過兩者內部原理不同,所以用法也不一樣:

  • 定時器(Timer)是用第3單元第2課學過的Publisher-Subscriber模式,視圖透過 .onReceive(定時器) 來控制自身狀態變化,相當於在視圖上裝一個接收天線,以接收定時器發出的訊號。

  • 時間軸不用Publisher-Subscriber模式,而是每次時間一到,直接透過「時間參數」送進匿名函式各個子視圖,由子視圖根據時間參數的變化來控制自身狀態。


時間軸送入匿名函式的「時間參數」是個物件,有兩個屬性,範例中會將「時間參數.date」,也就是觸發當下的時間顯示出來:

- date — 觸發當下的時間
- cadence — 節奏,即大約的週期(.live, .second, .minute)

範例第二部分是觀察「連續時間排程」animation,這是一個物件屬性。也有個物件方法取同名animation(),用物件方法可以進一步控制時間間隔以及排程暫停。
TimelineView(.animation) { 時間參數 in
let 本地曆法 = Calendar.current
let 秒數 = 本地曆法.component(.second, from: 時間參數.date)
let 奈秒 = 本地曆法.component(.nanosecond, from: 時間參數.date)
Text("\(時間參數.date)\n\(秒數):\(奈秒)")
.font(.title2)
.onAppear {
print(時間參數.date)
print(時間參數.cadence)
}
}


animation 字面意思是動畫,跟前面所提的動畫 Animation物件有關嗎?其實沒有直接關係,或許新的 Animation物件可以透過這個 animation排程來加以實現。

animation 時間排程原則上就是「儘量快」,如果CPU資源允許,會以每秒60次(60fps)以上的速度來更新子視圖,相當於時間間隔在0.0167秒以下,對人眼來說,幾乎是連續的,所以可名為「動畫排程」或「連續排程」。

範例中,我們利用第1單元第10課學過的Calendar物件,取出時間中的秒數以及小數點以下到奈秒,請留意animation排程的顯示速度。


觀察此執行結果,可以發現兩點疑問,與TimelineView特性很有關係,請讀者推敲看看:

問題一、畫面第一行的秒數更新總會慢半拍,稍晚於第二行,這是為什麼?
問題二、即使包在時間軸容器內,Text() 視圖的 .onAppear 還是只會執行一次,為什麼呢?

💡 註解
  1. 「時間參數」的 cadence 字面上是「節奏、起伏」的意思,做什麼用呢?時間軸視圖已經有時間排程當作參數,為什麼還需要知道節奏?這是因為子視圖並不知道整個排程,當每次子視圖被叫醒(執行)時,只知道現在時間(date),不知道上一次或下一次被叫醒的時間,透過時間參數的cadence,子視圖就大致知道變化的週期了。不過本單元並不會用到cadence。
  2. 嚴格來說,電腦時間都不是連續的,但相對於 periodic 週期性排程而言,animation 排程會讓人感覺沒有停頓,才能產生連續動畫的效果。雖然兩者都可以手動設定週期,但通常 periodic會用於較長週期(如0.1秒以上),而 animation用於較短週期。
  3. 在程式最後的視圖主體(body),我們省略了VStack,這是預設的排版方式。
    struct 時間軸視圖: View {
    var body: some View {
    週期性排程()
    連續時間排程()
    }
    }
4-4b 逐字顯示 TimelineView

時間軸視圖TimelineView可說是SwiftUI將定時器Timer加工包裝,做成視圖物件,以套用我們熟悉的宣告式語法以及眾多的修飾語功能,方便做出更有彈性的動態效果。

本節就用「時間軸視圖」配合「中文斷字」,將文章內容逐字顯示出來,以模仿打字的效果。文章內容我們選用蘇東坡著名的「水調歌頭」,可先看一下本文最下方實際執行的效果。完整範例程式如下:
// 4-4b 逐字顯示 TimelineView
// Created by Heman, 2022/04/05
import PlaygroundSupport
import SwiftUI

struct 逐字顯示: View {
let 字串參數: String
let 更新時間: Date
@State var 顯示內容 = ""
@State var 字串索引 = "".startIndex
var body: some View {
Text(顯示內容 + "_") // 底線 "_" 模仿游標
.onAppear {
字串索引 = 字串參數.startIndex
}
.onChange(of: 更新時間) {_ in
if 字串索引 < 字串參數.endIndex {
顯示內容 = 顯示內容 + String(字串參數[字串索引])
字串索引 = 字串參數.index(after: 字串索引)
}
}
}
}

struct 文章 {
let 標題: String
let 作者: String
let 內容: String
}

struct 宋詞選: View {
let 宋詞: 文章
init(_ 參數: 文章) { 宋詞 = 參數 }
var body: some View {
TimelineView(.periodic(from: .now + 0.5, by: 0.2)) { 時間參數 in
VStack {
Text("\(宋詞.標題) \(宋詞.作者)")
.font(.largeTitle)
.padding()
逐字顯示(字串參數: 宋詞.內容, 更新時間: 時間參數.date)
.font(.title)
.border(Color.red)
Spacer()
Text("\(時間參數.date)")
.padding()
}
}
}
}

let 水調歌頭 = 文章(
標題: "水調歌頭",
作者: "(宋)蘇軾",
內容: """
明月幾時有?
把酒問青天。
不知天上宮闕,
今夕是何年。
我欲乘風歸去,
又恐瓊樓玉宇,
高處不勝寒。
起舞弄清影,
何似在人間?

轉朱閣,
低綺戶,
照無眠。
不應有恨,
何事長向別時圓?
人有悲歡離合,
月有陰晴圓缺,
此事古難全。
但願人長久,
千里共嬋娟。
"""
)

PlaygroundPage.current.setLiveView(宋詞選(水調歌頭))


時間軸視圖較定時器Timer方便的一點,是TimelineView可以將「時間參數」送入所有子視圖中,本範例有兩個子視圖用到時間參數,一個是「逐字顯示」視圖,另一個是 Text("\(時間參數.date)"),會在螢幕最下方顯示一行現在時刻。
TimelineView(.periodic(from: .now + 0.5, by: 0.2)) { 時間參數 in 
VStack {
Text("\(宋詞.標題) \(宋詞.作者)")
.font(.largeTitle)
.padding()
逐字顯示(字串參數: 宋詞.內容, 更新時間: 時間參數.date)
.font(.title)
// .border(Color.red)
Spacer()
Text("\(時間參數.date)")
.padding()
}

Spacer()用來佔掉剩餘的螢幕空間,在這裡的效果是將 Text("\(時間參數.date)") 擠到螢幕最底下顯示。

我們使用週期性(periodic)排程,每0.2秒更新一次所有子視圖,包括其中的「逐字顯示」,「逐字顯示」是我們寫的新視圖,接受兩個參數,一是要顯示的「字串參數」,二是「更新時間」傳遞時間參數。

「逐字顯示」收到傳遞進來的「時間參數」後,是如何更新視圖內容呢?畢竟我們要顯示的並不是時間,而是字串參數。如果是過去用定時器(Timer)的情況下,我們會用 onReceive 修飾語來改變狀態變數,進而更新視圖,但改用時間軸視圖後已不是Publisher-Subscriber模式,不適用 onReceive。

答案是改用 onChange 修飾語,onChange 可以監控並比較視圖的某個屬性(變數或常數)是否改變,如果有改變,就執行尾隨的匿名函式。因此我們可在onChange匿名函式中,改變狀態變數,達到更新視圖的目的。程式碼如下:
struct 逐字顯示: View {
let 字串參數: String
let 更新時間: Date
@State var 顯示內容 = ""
@State var 字串索引 = "".startIndex
var body: some View {
Text(顯示內容 + "_")
.onAppear {
字串索引 = 字串參數.startIndex
}
.onChange(of: 更新時間) {_ in
if 字串索引 < 字串參數.endIndex {
顯示內容 = 顯示內容 + String(字串參數[字串索引])
字串索引 = 字串參數.index(after: 字串索引)
}
}
}
}


要做出逐字顯示的效果很簡單,每次更新視圖時,一一將「字串參數」的字元加到「顯示內容」末尾,並將字串索引指向下個索引即可:
顯示內容 = 顯示內容 + String(字串參數[字串索引])
字串索引 = 字串參數.index(after: 字串索引)


執行結果如下,注意其中Spacer()的排版效果,如果沒有Spacer(),畫面將會跑來跑去;當文章顯示完畢後,螢幕底下的時間還在繼續走,表示TimelineView並未停止:


💡 註解
  1. 我們在 onChange 所監視的屬性「更新時間」,其實是用 let 宣告為常數,為什麼還會改變?
  2. 以 on 開頭的視圖修飾語,通常是用來回應某些「事件」,這些事件可能來自使用者的手勢、作業系統的通知或是某個屬性的改變等等,故可稱為「事件修飾語」。on 當作時間介係詞,有「當...時」的意思,SwiftUI有以下幾個常見的事件修飾語。
    # 視圖修飾語 觸發事件時機 課程範例
    1 onAppear 視圖初次顯示時 第3單元3-2a
    2 onChange 特定屬性(變數/常數)有變化時 第3單元3-9d
    3 onDisappear 視圖消失時 第3單元3-9c
    4 onDrag 拖-放動作(drag & drop):移動時 -
    5 onDrop 拖-放動作(drag & drop):放開時 -
    6 onLongPressGuesture 長按螢幕手勢 -
    7 onReceive 收到發布的訊息(如定時器) 第3單元3-2d
    8 onSubmit 鍵盤輸入按下ENTER時 第3單元3-10d
    9 onTapGuesture 輕點螢幕手勢 第2單元2-10a
CUNNING
如果是要收證交所的股價,每隔幾秒更新一次,用onReceive?
雪白西丘斯
用 Timer + onReceive 或 TimelineView + onChange,兩個方法都可以。證交所的例子未牽涉到繪圖,Timer + onReceive 會容易一些。
4-4c 斷字、停頓 TimelineView(.explicit())

上節利用TimelineView週期性排程,逐字顯示文章內容的效果,其實用定時器Timer也做得到,有沒有定時器做不到或很難做,但TimelineView可輕易做到的?

有的。上節範例從頭到尾固定速度,這樣其實有點呆板,最好是每句能夠停頓一下,也就是說,當遇到換行字元(”\n”)時,要停留稍長一些。

想達到這樣的效果,因為每行長短不一,並沒有明確的週期性,若用定時器就會非常麻煩,還好TimelineView 提供另一個預設排程 .explicit(),正適合這個情況。

explicit 英文意思是「明確的」,也就是明確地將排程時間一一列出,就像火車時刻表一樣,列出每一班列車的明確時間。因此,.explicit() 可稱為「明確排程」或「有限排程」,因為不像 .animation 或 .periodic() 的排程會一直跑下去,.explicit() 的時刻表是有限的,排程結束TimelineView就會停止更新。

明確排程的語法是TimelineView(.explicit(時刻表)),需要一個「時刻表」當作參數,「時刻表」資料類型是時間陣列 [Date],舉例示意如下:
// 非正式程式碼,僅供示意
let 現在 = Date()
var 時刻表 = [現在, 現在+1.0, 現在+1.5, 現在+2.0, 現在+5.0]
....
TimelineView(.explicit(時刻表)) {
....
}

第1單元第10課提過Date, DateFormatter, Calendar三者關係,Date 本質上是一個實數(Double類型),代表從基準時間(格林威治時間2001年1月1日凌晨0分0秒)之後的秒數,可精確到奈秒(10⁻⁹秒),因此上例中,「現在+1.0」就是「現在」之後1秒的時間。

類似上例,我們要做的,就是根據一個字串計算出「時刻表」,以一般的閱讀速度來算,每個字元間隔0.2秒,遇到換行則間隔0.5秒,以此邏輯設計函式 — 傳入字串參數,傳回計算出來的「時刻表」:
func 字句排程(_ 字串參數: String) -> [Date] {
let 現在時刻 = Date()
let 間隔 = 0.2 // 秒
let 停頓 = 0.5 // 秒
var 時刻表: [Date] = [現在時刻 + 停頓]
var 累計秒數 = 停頓
var 字元索引 = 字串參數.startIndex
while 字元索引 < 字串參數.endIndex {
if 字串參數[字元索引] == "\n" {
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 停頓]
累計秒數 = 累計秒數 + 停頓
} else {
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 間隔]
累計秒數 = 累計秒數 + 間隔
}
字元索引 = 字串參數.index(after: 字元索引)
}
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 間隔]
return 時刻表
}

這個「字句排程」函式是整個程式最關鍵的地方,函式主要部分,是利用上節用過的字元索引,在while迴圈中逐字掃瞄,如果遇到換行字元,時刻表就增加一筆0.5秒的停頓:
while 字元索引 < 字串參數.endIndex {
if 字串參數[字元索引] == "\n" {
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 停頓]
累計秒數 = 累計秒數 + 停頓
}
....

否則若是一般字元或標點符號,則增加0.2秒的間隔:
    } else {
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 間隔]
累計秒數 = 累計秒數 + 間隔
}

以上計算時刻表的邏輯示意如下:


蘇軾「水調歌頭」共計133字元(含標點與換行字元),函式傳回的時刻表有135筆時間,因為要在時刻表最前和最後額外加一筆資料,這與TimelineView(.explicit())運作方式有關。

有了計算時刻表的「字句排程」函式,我們就可以使用明確排程來執行時間軸視圖:
TimelineView(.explicit(字句排程(宋詞.內容))) { 時間參數 in 
....
}

程式其他部分,與上一節完全相同,這樣就變成有節奏變化的逐字顯示,未來還可以加上Siri朗讀,變成一個有聲閱讀的App。

完整範例程式如下,「字句排程」函式就放在「宋詞選」視圖裡面,當作物件方法。為了讓節奏明快,突出停頓時間,我們將字元間隔縮短為0.12秒,停頓仍為0.5秒:
// 4-4c 逐字顯示+停頓 TimelineView(.explicit())
// Created by Heman, 2022/04/05
// Revised by Heman, 2022/04/11
import PlaygroundSupport
import SwiftUI

struct 逐字顯示: View {
let 字串參數: String
let 更新時間: Date
@State var 顯示內容 = ""
@State var 字串索引 = "".startIndex
var body: some View {
Text(顯示內容 + "_") //底線 "_" 模仿游標
.onAppear {
字串索引 = 字串參數.startIndex
}
.onChange(of: 更新時間) {_ in
if 字串索引 < 字串參數.endIndex {
顯示內容 = 顯示內容 + String(字串參數[字串索引])
字串索引 = 字串參數.index(after: 字串索引)
}
}
}
}

struct 文章 {
let 標題: String
let 作者: String
let 內容: String
}

struct 宋詞選: View {
let 宋詞: 文章
init(_ 參數: 文章) { 宋詞 = 參數 }
var body: some View {
TimelineView(.explicit(字句排程(宋詞.內容))) { 時間參數 in
VStack {
Text("\(宋詞.標題) \(宋詞.作者)")
.font(.largeTitle)
.padding()
逐字顯示(字串參數: 宋詞.內容, 更新時間: 時間參數.date)
.font(.title)
// .border(Color.red)
Spacer()
Text("\(時間參數.date)")
.padding()
}
}
}
func 字句排程(_ 字串參數: String) -> [Date] {
let 現在時刻 = Date()
let 間隔 = 0.12 // 秒
let 停頓 = 0.5 // 秒
var 時刻表: [Date] = [現在時刻 + 停頓]
var 累計秒數 = 停頓
var 字元索引 = 字串參數.startIndex
while 字元索引 < 字串參數.endIndex {
if 字串參數[字元索引] == "\n" {
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 停頓]
累計秒數 = 累計秒數 + 停頓
} else {
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 間隔]
累計秒數 = 累計秒數 + 間隔
}
字元索引 = 字串參數.index(after: 字元索引)
}
時刻表 = 時刻表 + [現在時刻 + 累計秒數 + 間隔]
return 時刻表
}
}

let 水調歌頭 = 文章(
標題: "水調歌頭",
作者: "(宋)蘇軾",
內容: """
明月幾時有?
把酒問青天。
不知天上宮闕,
今夕是何年。
我欲乘風歸去,
又恐瓊樓玉宇,
高處不勝寒。
起舞弄清影,
何似在人間?

轉朱閣,
低綺戶,
照無眠。
不應有恨,
何事長向別時圓?
人有悲歡離合,
月有陰晴圓缺,
此事古難全。
但願人長久,
千里共嬋娟。
"""
)

PlaygroundPage.current.setLiveView(宋詞選(水調歌頭))


程式執行過程如下,每一行的停頓相當明顯,效果不錯:

註解
  1. 最後排程結束時,TimelineView共執行135次,但onChange卻只執行133次,為什麼呢?
4-4d 測試「明確排程」

上一節範例使用的「明確排程」TimelineView(.explicit()),為什麼 onChange 執行次數比「時刻表」少2次?必須釐清這種行為是特例還是通則,才不會誤解而產生bug。以下範例做個小測試,確認「明確排程」的行為模式:
// 4-4d TimelineView(.explicit()) 測試
// Created by Heman, 2022/04/15
import PlaygroundSupport
import SwiftUI

extension Date {
var 秒: Int {
return Calendar.current.component(.second, from: self)
}
var 奈秒: Int {
return Calendar.current.component(.nanosecond, from: self)
}
}

struct 排班: View {
let 參數: Date
var body: some View {
let 當下 = Date()
Text("排班當下秒數:\(當下.秒).\(當下.奈秒)")
.onAppear {
print("初次顯示秒數(實際時間):\(當下.秒).\(當下.奈秒)")
}
Text("排班參數秒數:\(參數.秒).\(參數.奈秒)")
.onAppear {
print("初次顯示秒數(參數時間):\(參數.秒).\(參數.奈秒)")
}
.onChange(of: 參數) { 更新值 in
print("更新時間秒數:\(更新值.秒).\(更新值.奈秒)")
}
}
}

struct 明確排程: View {
let 起點 = Date()
var body: some View {
let 時刻表 = [起點+5, 起點+10, 起點+15, 起點+20]
TimelineView(.explicit(時刻表)) { 時間參數 in
Text("開始:\(起點)")
.onAppear {
print("起始時間:\(起點)")
print("時刻表:\(時刻表)")
}
排班(參數: 時間參數.date)
}
}
}

PlaygroundPage.current.setLiveView(明確排程())

為了顯示精確時間,我們先設計兩個「時間Date」的延伸屬性「秒、奈秒」,在第3單元第10課範例3-10b介紹過擴充物件結構的 extension,對任何現存的物件,都可擴增物件屬性或方法,用法超級簡單:
extension Date {
var 秒: Int {
return Calendar.current.component(.second, from: self)
}
var 奈秒: Int {
return Calendar.current.component(.nanosecond, from: self)
}
}

不過 extension 有個限制上次並沒有提到,若要增加物件屬性,只能用 “computed property”,也就是指定變數值時,後面不是接等號,而是用 { } 來計算變數值。

還記得這裡的self是指什麼嗎?同樣在第3單元第10課,詳細解釋過物件self的意義,指的是使用這個屬性或方法的「物件實例」(instance),例如我們範例用「當下.奈秒」時,self 就代表「當下」這個實例,會取出「當下」這個時間的奈秒數。

接下來,我們定義「時刻表」含4筆時間,各筆間隔5秒:
struct 明確排程: View {
let 起點 = Date()
var body: some View {
let 時刻表 = [起點+5, 起點+10, 起點+15, 起點+20]
....
}
}

就像我們慣用的視圖主體(body),其實也是用 “computed property” 的語法,在 { } 裡面計算 body 變數值。{ } 就像一個函式,裡面可以宣告函式內部使用的變數或常數,在此宣告一個簡單的「時刻表」後,就可以呼叫時間軸視圖了:
TimelineView(.explicit(時刻表)) { 時間參數 in
Text("開始:\(起點)")
.onAppear {
print("起始時間:\(起點)")
print("時刻表:\(時刻表)")
}
排班(參數: 時間參數.date)
}

除了畫面會顯示時間之外,重點還放在控制台的輸出,在一開始會輸出起始時間與整個時刻表。然後在「排班」視圖中,會在 onChange 輸出時間參數,這樣就可以觀察到更新幾次:
.onChange(of: 參數) { 更新值 in
print("更新時間秒數:\(更新值.秒).\(更新值.奈秒)")
}

結果發現,雖然時刻表有4筆時間,但子視圖「排班」只執行3次,而onChange只有2次更新。


下圖是視圖階層關係,從控制台的輸出可以觀察到以下幾點:

1. 最先顯示的是最遠的孫視圖畫面,顯示時間的秒數35,是「時刻表」的第一筆時間
2. 顯示倒數第2個孫視圖,實際時間的秒數30,即執行到此當下的時間
3. 然後顯示子視圖畫面,開始的時間是14:20:30,所以第一筆時刻表是5秒後14:20:35
4. 「時刻表」第2、3筆時間(秒數40, 45),有觸發 onChange 事件
5. 「時刻表」最後一筆時間(秒數50),TimelineView並未更新子視圖


透過以上觀察,在「時刻表」的最後一筆,時間軸的確未更新子視圖,所以「排班」只執行3次,而「時刻表」第一筆時間(不管是過去還是未來)則是用來初始化子視圖,之後第二、三筆時間才會觸發 onChange 事件,所以 onChange 的確會比「時刻表」少執行2次。
4-4e 關於 “Computed property”

以下兩種變數宣告有何差別?
var 奈秒 = 100 + 1
var 奈秒二: Int {
return 100 + 1
}

雖然兩者都是經過「運算」來設定變數值,但第一種用等號的稱為 “stored property” (儲存變數或儲存屬性),第二種用 { } 稱為 “computed property” (計算變數或計算屬性),要了解兩者的差別,必須先懂一點程式語言的基礎理論。

當我們在 Swift Playgrounds 按下「執行我的程式碼」,程式是如何執行的呢?其實可細分為三個階段:
  • 第一階段稱為「編譯」(Compile),這個階段會檢查語法,並將原始程式碼轉驛成CPU指令

  • 第二階段為「連結載入」(Link & Load),會將編譯過的程式放入記憶體準備執行

  • 第三個階段才正式「執行」(Run)

一般較常用的 “stored property”,在第二階段就會確定記憶體位置,也就是在執行階段之前就先配置好記憶體儲存空間;而 “computed property” 則是在第三階段,也就是執行階段(run-time),才會臨時配置記憶體以暫存計算結果。

這兩者有點像某些演唱會,預售階段買票的人,可以事先劃座位,開演前就能知道自己位置,就像 “stored property”;臨時買票進場的人,沒有固定座位(自由座),”computed property”就是如此,沒有固定的記憶體位置。

為什麼 extension 擴充物件時,擴增屬性只能用 “computed property” 來設定呢?這是因為執行到 extension 之前,可能就已有該物件的實例存在,當 extension 擴增屬性之後,那些已存在的物件實例如何增加新的屬性呢?當然不可能回頭預留座位(記憶體空間),所以只能在執行階段動態配置了。

想知道自己對於 “Computed property” 與 “Stored property” 是否已了解,可以測試以下小程式,猜猜看這兩段程式碼的輸出是否一樣?
// 4-4e Computed property
// Tested by Heman, 2022/04/16
import Foundation

var 奈秒 = Calendar.current.component(.nanosecond, from: Date())
print(奈秒, 奈秒, 奈秒)

var 奈秒二: Int {
Calendar.current.component(.nanosecond, from: Date())
}
print(奈秒二, 奈秒二, 奈秒二)


這個測試的輸出有點令人意外。第一段用 stored property 宣告的「奈秒」,輸出結果是3個同樣的數值,很正常,但是第二段用 computed property 宣告的「奈秒二」,輸出結果不僅跟「奈秒」值不同,而且3個數值竟然彼此也不一樣,為什麼會這樣?
var 奈秒 = Calendar.current.component(.nanosecond, from: Date())
print(奈秒, 奈秒, 奈秒)
// 輸出結果:493368983 493368983 493368983

var 奈秒二: Int {
Calendar.current.component(.nanosecond, from: Date())
}
print(奈秒二, 奈秒二, 奈秒二)
// 輸出結果:496469974 496510982 496538043

用 stored property 宣告的變數「奈秒」,等號之後的運算只會計算一次,計算結果儲存在「奈秒」所在的記憶體位置上,之後引用「奈秒」時,就從記憶體位置上取值,所以總是取得同樣的值。

用 computed property 宣告的變數「奈秒二」,每次引用時,都會重新計算一次 { } 的運算過程,因此引用3次就會計算3次,取得3次不同時間。

如此一來,computed property 像不像函式?跟函式的差別只是沒有參數(或許所需參數在物件內部已自給自足),就像上一節4-4d在extension中定義的「奈秒」一樣。

這也難怪在第1課(4-1b)介紹Animation物件時,會有同樣名稱的屬性與方法,例如 Animation.linear 是物件屬性,而 Animation.linear() 是物件方法。很可能前者(Animation.linear)就是 computed property,跟後者一樣,都是即時運算得到的值。

那麼用 computed property 的屬性或方法,哪一種好呢?若不需要額外參數的話,就可用 computed property 設為屬性,比較便捷。

經過這個測試與說明,”stored” property 與 “computed” property 的命名,是不是更清楚了。

💡 註解
  1. Xcode 與 Swift Playgrounds 底層所用的編譯程式(Compiler)是用 LLVM 所開發,可說是整個Apple軟體生態圈最重要的核心,LLVM 最初在2002年左右由 Swift 語言之父 Chris Lattner 所設計,目前仍由 Chris Lattner與一群(2千多位)工程師共同維護並持續改版。
  2. 值得一提的是,本系列所有課程都採用Swift Playgrounds寫程式,這個 App 在2016年發表的最初版本也是 Chris Lattner 所寫。
  3. 我們曾在第1單元將「函式」比喻為「自動販賣機」,每次輸入不同選擇,會有不同飲料出來。對 “computed property” 而言,有點像是只賣單一物品的「面紙自動販賣機」,每次取值背後同樣有運算過程,只是沒有多樣選擇,出來的都是(新的)一包面紙。而 ”stored property” 則像是「盒子」,可以存放資料,每次取值總是拿到上次存放的內容。
第5課 畫布Canvas

在去(2021)年發布的SwiftUI更新中,最受到矚目的兩個物件是TimelineView(時間軸視圖)與Canvas(畫布或繪畫板),如果說TimelineView讓我們能夠精確控制子視圖的時間,那麼,Canvas則是讓我們精確控制視圖的每個畫素。

不過,為什麼需要Canvas?我們已有許多視圖物件,包括文字、圖片、幾何圖形、圖示、影片,還有動畫的效果等等,有什麼是之前視圖做不到,只有Canvas才做得到的嗎?

是的,Canvas 能做的太多了,從點、線、面、數學函數作圖,到影像處理、3D立體渲染,Canvas 提供我們一個豐富的電腦繪畫(Computer Graphics)工具。光是用Canvas就足以寫一本書,本單元後半課程,基本上都跟Canvas相關。

此外,Canvas 還提供過去我們學過的視圖所沒有的一項重要資料:視圖尺寸。例如在第1課的文字跑馬燈,我們如何確定一段文字的寬度,需要多少位移才剛好讓文字從螢幕外跑進來?這就得靠Canvas取得視圖尺寸,才能精確控制。

以下我們先以一個簡單範例,來看看Canvas所具備的基本功能。
// 4-5a 畫布 Canvas 
// Created by Heman, 2022/04/16
import PlaygroundSupport
import SwiftUI

struct 畫布: View {
var body: some View {
Label("Swift程式設計第4單元", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心點 = CGPoint(x: 寬/2, y: 高/2)
let 半幅 = CGSize(width: 寬/2, height: 高/2)
let 全框 = CGRect(origin: .zero, size: 尺寸)
let 左上框 = CGRect(origin: .zero, size: 半幅)
let 右下框 = CGRect(origin: 中心點, size: 半幅)
let 字串 = "↖︎畫布座標原點\n↔︎寬\(寬)點 x ↕︎高\(高)點"
圖層.draw(Text(字串).font(.title), in: 全框)
圖層.draw(Image(systemName: "aqi.medium"), in: 左上框)
圖層.draw(Image(systemName: "rectangle"), in: 右下框)
}
.foregroundColor(.cyan)
.border(Color.red)
Text("現在時間\(Date())")
.padding()
}
}

PlaygroundPage.current.setLiveView(畫布())

範例中有3個視圖,分別是Label, Canvas 與 Text,顯示如下圖。Canvas 本身也是一個視圖,在螢幕上所佔位置與大小,由上層視圖(如VStack或根視圖)所配置。


注意這裡不需用Spacer()就可將Text壓到螢幕最底下,也就是說,Canvas 不像 Label 或 Text 有預設尺寸,如果沒有指定 .frame() 大小的話,會佔掉剩餘的螢幕空間。

雖然Canvas 是個視圖,有尾隨匿名函式 { },但並非視圖容器(View Container),這是Canvas獨特的地方,在Canvas { } 裡面的元素,不是其他視圖,而是底層的繪畫物件。

在Apple的物件庫中,最底層(最基本)的2D繪畫物件庫稱為 Core Graphics(Core是核心的意思),其中物件都以 CG 開頭命名,其中幾個會先用到的基本物件如下:
# 物件名稱 中文名稱 用途說明
1 CGFloat 浮點數 繪圖用的基本數值(等同於 64-bit Double類型)
2 CGSize 寬高尺寸(點) (width, height) 用兩個CGFloat定義螢幕寬、高點數
3 CGPoint 座標點 (x, y) 用兩個CGFloat值定義螢幕座標位置
4 CGRect 矩形/畫框/視框 (origin, size) 指定矩形左上角位置(原點)與寬高尺寸
5 CGSize.zero 零尺寸 (width: 0.0, height: 0.0)
6 CGPoint.zero 原點(origin) (x: 0.0, y: 0.0)
7 CGRect.zero 空畫框 (CGPoint.zero, CGSize.zero) == (0, 0, 0, 0)

Canvas 語法和其他視圖容器很不一樣,{ } 裡面不是寫視圖與修飾語,而是寫繪圖指令,Canvas 會傳遞兩個很重要的參數到 { } 之中 — 圖層(context)與尺寸(size):
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心點 = CGPoint(x: 寬/2, y: 高/2)
let 半幅 = CGSize(width: 寬/2, height: 高/2)
let 全框 = CGRect(origin: .zero, size: 尺寸)
let 左上框 = CGRect(origin: .zero, size: 半幅)
let 右下框 = CGRect(origin: 中心點, size: 半幅)
let 字串 = "↖︎畫布座標原點\n↔︎寬\(寬)點 x ↕︎高\(高)點"
圖層.draw(Text(字串).font(.title), in: 全框)
圖層.draw(Image(systemName: "aqi.medium"), in: 左上框)
圖層.draw(Image(systemName: "rectangle"), in: 右下框)
}

「圖層」是我們可以繪圖的地方,Canvas是一個數位畫布,可包含多個圖層,圖層之間(類似用ZStack)疊加起來就形成整個Canvas視圖,上面程式片段最後3行在圖層上畫(draw)出三個元素,一個Text字串以及兩個Image圖形。

「尺寸」類型是CGSize,為上層視圖配置給Canvas的視圖大小,包含寬(width)與高(height)兩個屬性,寬(width)相當於視圖範圍內X軸的最大值,高(height)則是Y軸最大值。

要在Canvas圖層裡面繪製物件,常用到CGRect指定畫框位置,CGRect畫框包含一個點座標與寬高尺寸,點座標代表畫框左上角位置,例如最後一行我們將一個矩形畫在「右下框」,左上角在中心點。

Canvas 對於元素的佈局,也跟之前的視圖容器排版方式完全不一樣,Canvas裡面的Text文字會對齊CGRect畫框左上角,Image圖形則對齊左下角。

因此,我們通常在Canvas裡面先定義一些基本的點、尺寸或畫框位置,這樣在圖層中畫圖,就容易多了,如下圖:


由此可見,Canvas 之所以能夠帶給我們更多的繪圖方式,一方面是採用比較低階(基礎)的繪圖功能,一方面則是所有細節(包括位置、尺寸等)都可由程式設計師指定,雖然稍微麻煩一些,但是帶來更多彈性。

💡 註解
  1. Canvas 傳入匿名函式的參數名稱,官方文件慣用的原文是 context, size,其中 context 是 GraphicsContext 物件類型,context 意思為「上下文」或「週遭環境」,中文比較不容易理解,在此相當於我們說的「圖層」,圖層原文是 Layer。
  2. Canvas 繪圖所用的螢幕座標,如第2單元所介紹,左上角為座標原點,水平X軸往右為正,垂直Y軸往下為正(Y軸方向與數學座標相反)。
  3. 螢幕座標是個相對座標。整個螢幕稱為「全域座標」(global coordinate),原點是螢幕左上角;視圖所在的視框(frame)範圍稱為「區域座標」(local coordinate),原點為視框左上角;繪圖元素以CGRect指定的視框則可稱為子座標(child coordinate),以CGRect左上角為原點。
  4. 即便在Canvas的圖層中畫出Text或Image視圖物件,在Canvas中仍視為繪圖元素,而不是子視圖。
  5. 從執行畫面可看到視圖尺寸寬437.0點、高618.0點,單位既然是螢幕畫素(點),為什麼資料類型不是整數,而是實數(CGFloat)呢?
您的文章是我學習的動力!
雪白西丘斯
謝謝,不敢當。SwiftUI很多新東西,大家共同學習,一起成長。
4-5b Path 畫直線(座標軸)

早期電腦的繪圖系統或遊戲畫面,都是以「點」(pixel)為基礎,以整數值計算,雖然較節省CPU時間,但圖形放大後會出現鋸齒狀的邊緣,顯得不夠精緻。

Canvas 與 Core Graphics 的繪圖系統則源自 Apple 的 Quartz 2D 框架,是以「向量」(Vector)為設計基礎,能夠在不同設備或不同座標系間轉換,放大縮小完全不失真,計量數值則採用實數。

所謂「向量」(Vector),是指有方向性的(直線)線段,一條線段有兩個端點,向量會區分起點和終點。因此,在Canvas中的繪圖,是以向量線條為基本要素,可以畫直線、折線、弧線、曲線,再由線條構成各種形狀(Shape)。

在SwiftUI 中畫線條,需要一個 Path 物件,Path 字面意思是「路徑」,但在本課稱之為「畫筆」,較容易理解。以下程式片段,會在圖層中畫出水平中線與垂直中線:
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 上中 = CGPoint(x: 寬/2, y: 0)
let 下中 = CGPoint(x: 寬/2, y: 高)
let 左中 = CGPoint(x: 0, y: 高/2)
let 右中 = CGPoint(x: 寬, y: 高/2)
var 畫筆 = Path()
畫筆.move(to: 上中)
畫筆.addLine(to: 下中)
畫筆.move(to: 左中)
畫筆.addLine(to: 右中)
圖層.stroke(畫筆, with: .color(.blue), lineWidth: 1)
}

前7行定義各點位置,後6行分別對應以下6個動作,相對位置請參考下圖:

1. var 畫筆 = Path() 取得一支新的畫筆
2. 將畫筆移(move)到「上中」點
3. 從現在位置「上中」畫直線(addLine)到「下中」為止,畫成垂直中線
4. 提筆移(move)到「左中」點
5. 畫直線(addLine)到「右中」點,完成水平中線,這時候還不會顯示在螢幕上
6. 用圖層的「筆觸」(stroke)並指定顏色與線寬(lineWidth),才真的顯示在螢幕上


記得,用「畫筆」(Path)猶如在心中描繪,只見影不見人,要等揮毫(stroke)落到「圖層」(context)後,才會現出真形,顯示在螢幕。所以不用每畫一筆,就呼叫一次圖層,而是將整個圖案畫完之後,再到圖層顯示出來。

下表是 Path 物件「畫筆」的物件方法,用來描繪各種線條,本單元會示範前面6個比較重要的功能,後面6個相對容易,可以自行嘗試:
# Path 物件方法 參數 用途說明 課程章節
1 move(to: CGPoint) 1點座標 移動畫筆 4-5b
2 addLine(to: CGPoint) 1點座標 畫直線 4-5b
3 addLines([CGPoint]) 多點座標陣列 畫折線 4-5c
4 addCurve() 3個點座標 畫曲線(三階貝茲曲線) 4-7a
5 addQuadCurve() 2個點座標 畫曲線(二階貝茲曲線) 4-7a
6 addArc() 5個參數 畫(圓)弧線 4-5c
7 addEllipse(in: CGRect) 1個畫框 畫橢圓 -
8 addRect(CGRect) 1個畫框 畫矩形 -
9 addRects([CGRect]) 多畫框陣列 畫多個矩形 -
10 addRoundedRect() 3個參數 畫圓角矩形 -
11 addPath(其他畫筆) 1個畫筆物件 加入其他畫筆路徑 -
12 closeSubPath 無參數 畫閉合線段(回到起點) -

本節先熟悉 move() 與 addLine() 畫直線的方法。畫完中線後,我們將中線每隔10點加上「刻度」:
var 刻度 = 0.0
while 刻度 < 中心.x {
let 起 = 中心.y - 3
let 訖 = 中心.y + 3
畫筆.move(to: CGPoint(x: 中心.x - 刻度, y: 起))
畫筆.addLine(to: CGPoint(x: 中心.x - 刻度, y: 訖))
畫筆.move(to: CGPoint(x: 中心.x + 刻度, y: 起))
畫筆.addLine(to: CGPoint(x: 中心.x + 刻度, y: 訖))
刻度 += 10.0
}

這段程式碼會在水平軸中心兩側各加一條垂直短線,當作水平軸刻度,每10點畫一條刻度:


用同樣方法,再給垂直軸加上刻度。刻度還可以進一步改善,除了每10點一條刻度之外,每50點的刻度可以稍長一些,這樣看起來比較清楚。只要將上面起、訖兩行改成:
let 起 = (刻度.remainder(dividingBy: 50) == 0) ? 中心.y-6 : 中心.y-3 
let 訖 = (刻度.remainder(dividingBy: 50) == 0) ? 中心.y+6 : 中心.y+3

若是整數求餘數,我們可以用 % 符號,至於對實數求餘數,須用物件方法 remainder(),remainder 就是英文「餘數」,參數 dividingBy 則是「除以」。

另外,為了示範多個「圖層」,我們將水平軸與垂直軸分別畫在兩個圖層上,新增圖層的方法很簡單,進入 Canvas { } 之後,用 var 新圖層 = 圖層 就可複製空白圖層,最後在畫垂直軸時,以 新圖層.stroke() 即可。完整程式碼如下:
// 4-5b 直線(座標軸) Path 
// Created by Heman, 2022/04/19
import PlaygroundSupport
import SwiftUI

struct 座標軸: View {
var x = true
var y = true
var body: some View {
Canvas { 圖層, 尺寸 in
var 新圖層 = 圖層
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 上中 = CGPoint(x: 寬/2, y: 0)
let 下中 = CGPoint(x: 寬/2, y: 高)
let 左中 = CGPoint(x: 0, y: 高/2)
let 右中 = CGPoint(x: 寬, y: 高/2)
if x == true {
var 畫筆 = Path()
畫筆.move(to: 左中)
畫筆.addLine(to: 右中)
var 刻度 = 0.0
while 刻度 < 中心.x {
let 起 = (刻度.remainder(dividingBy: 50) == 0) ? 中心.y-6 : 中心.y-3
let 訖 = (刻度.remainder(dividingBy: 50) == 0) ? 中心.y+6 : 中心.y+3
畫筆.move(to: CGPoint(x: 中心.x+刻度, y: 起))
畫筆.addLine(to: CGPoint(x: 中心.x+刻度, y: 訖))
畫筆.move(to: CGPoint(x: 中心.x-刻度, y: 起))
畫筆.addLine(to: CGPoint(x: 中心.x-刻度, y: 訖))
刻度 += 10.0
}
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)
}
if y == true {
var 畫筆 = Path()
畫筆.move(to: 上中)
畫筆.addLine(to: 下中)
var 刻度 = 0.0
while 刻度 < 中心.y {
let 起 = (刻度.remainder(dividingBy: 50) == 0) ? 中心.x-6 : 中心.x-3
let 訖 = (刻度.remainder(dividingBy: 50) == 0) ? 中心.x+6 : 中心.x+3
畫筆.move(to: CGPoint(x: 起, y: 中心.y+刻度))
畫筆.addLine(to: CGPoint(x: 訖, y: 中心.y+刻度))
畫筆.move(to: CGPoint(x: 起, y: 中心.y-刻度))
畫筆.addLine(to: CGPoint(x: 訖, y: 中心.y-刻度))
刻度 += 10.0
}
新圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 1)
}
}
}
}

struct 畫布: View {
@State var showX = true
@State var showY = true
var body: some View {
ZStack(alignment: .bottomTrailing) {
座標軸(x: showX, y: showY)
VStack {
Toggle("X軸", isOn: $showX)
Toggle("Y軸", isOn: $showY)
}
.frame(width: 100)
.font(.title)
}
}
}

// PlaygroundPage.current.setLiveView(座標軸(y: false))
PlaygroundPage.current.setLiveView(畫布())

最後的螢幕視圖,我們在右下角加上兩個 Toggle() 開關,用來顯示或隱藏座標軸,Toggle() 用法非常簡單,跟一個狀態變數綁定即可,「開(On)」的時候狀態變數值為 true,「關(Off)」的時候為 false:
Toggle("X軸", isOn: $showX)
Toggle("Y軸", isOn: $showY)

之前在第3單元3-3b提過,若需用 ‘$’ 綁定的狀態變數,是目前唯一不能用中文命名的地方,一定得用英文命名,否則加了 ‘$’ 之後編譯程式無法辨識中文變數名稱。因此這裡命名為 showX 與 showY,並且在「座標軸」視圖中增加兩個變數屬性 x, y,以便透過參數傳遞:
座標軸(x: showX, y: showY)

執行畫面如下:


💡 註解
  1. 若向量的起點與終點重合,向量的長度是多少?
  2. 以下程式碼能否畫出中心點?
    畫筆.move(to: 中心)
    畫筆.addLine(to: 中心)
    圖層.stroke(畫筆, with: .color(.blue), lineWidth: 1)

  3. 蘇東坡曾稱讚好友文同「畫竹必先得成竹於胸中」,就好比 SwiftUI 先用 Path 描繪線條輪廓,再用 Canvas 圖層顯示出來一樣,也算是「胸有成竹」的畫法。
  4. stroke 有好多意思,動詞有(一次)揮棒動作、(游泳)滑水動作、(寵物)撫摸等意思,名詞有(一個)筆畫、(畫上)一筆、(一次)揮動等,stroke 還有個不好的詞意 -- 「中風」,又稱腦溢血。
  5. 上一節用 圖層.draw() 畫出 Image 圖示,本節用 圖層.stroke() 畫座標軸線條,圖層的 draw() 和 stroke() 有何不同呢?
文章分享
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)

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