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

前言

第2單元介紹過SwiftUI基本觀念與物件,從核心觀念「視圖(View)」,到各類視圖物件與視圖修飾語(View modifier),不過限於篇幅,著重介紹版面編排(Layout)相關物件,包括VStack/HStack/ZStack、LazyVGrid、ForEach、List等。

接下來我們準備學習SwiftUI另一個重要組成:動畫與平面繪圖,核心物件包括Animation, TimelineView, Canvas, Path等,如果說第2、第3單元是以靜態的資料圖表為主,那麼第4單元則開始「動態視覺化」,並適時加入按鈕、觸控手勢等互動元件,讓使用者的互動體驗更完善。

課程大綱以10課為一個單元,前4課介紹文字處理與相關動畫,包括文字跑馬燈、旋轉、閃爍、斷句、逐字變化等,後6課則是2D繪圖,包括圓與正多邊形、對稱圖形、軌跡動畫、函數繪圖、圖片輪播等。

第4單元目錄如下:
第1課 文字動畫(Animation 物件)
  • 4-1b 基本的6種文字動畫
  • 4-1c 雨燕飛翔(組合動畫)
第2課 帶屬性文字 AttributedString
  • 4-2b 屬性容器 AttributeContainer
  • 4-2c Markdown 標示語言
第3課 字串分解
  • 4-3b 字串分解:中文斷句
  • 4-3c 中文斷句 修正版
  • 4-3d “Index out of range” bug 修正
第4課 時間軸視圖 TimelineView
  • 4-4b 中文逐字處理 TimelineView
  • 4-4c 斷字、停頓 TimelineView(.explicit())
  • 4-4d 測試「明確排程」
  • 4-4e 關於 “Computed property”
第5課 畫布 Canvas
  • 4-5b 畫直線(座標軸) Path
  • 4-5c 圓與正三角形 Canvas + Path
第6課 Canvas + TimelineView
  • 4-6b 滾動
  • 4-6c 滾動軌跡(餘弦函數)
  • 4-6d 外擺線
第7課 貝茲曲線(Bezier Curve)
  • 4-7b 時間曲線:正規化座標
  • 4-7c 貝茲曲線軌跡
  • 4-7d 仿射變換(CGAffineTransform)
第8課 正多邊形 — Shape
  • 4-8b 漸層多角星(Gradient)
  • 4-8c 生命之花 Flower of Life
第9課 圖片輪播(Carousel)
  • 4-9b 網路圖片輪播
  • 4-9c 手動輪播(一)
  • 4-9d 手動輪播(二)
  • 4-9e 相簿瀏覽器App
  • 4-9f App解說
第10課 App-2: 芝加哥藝術博物館v2
  • 補充:解決App網路無法連線問題 — Swift Playgrounds 4.1 for macOS
  • 4-10b 芝加哥藝術博物館v2 (1)畫家選單(@Binding)
  • 4-10c App (Part2) 搜尋作品列表
  • 4-10d App (Part3) 取得作品資訊
  • 4-10e App (Part4) 載入圖片
  • 補充:除錯(Debug)
  • 4-10f App (Part5) 圖片輪播
第4單元結語

課程對象仍以高中生程度為主,行文會盡量簡潔,避免用太多英文術語,每課以完整範例程式來介紹新的物件語法,而自己定義的變數、常數、函式、類型等名稱,儘量採用中文命名,不過英文關鍵字還是必要,如遇到較難的英文會稍加解釋。

本單元完全採用 Swift Playgrounds App 編寫程式(不需要Xcode!),硬體只需一台iPad平板或Mac電腦(最近五年內的型號都適用),須安裝Swift Playgrounds 4.0以上版本。

所需的基礎知識,會用到前面第1, 2單元的觀念與物件語法,如果沒有基礎的同學,請盡量循序漸進,這樣基礎才能打得更穩。

學習路線

後續單元還有:

另外,本單元課程已整合為一個App,並上架App Store:搜尋「動畫與繪圖」Swift程式設計[第4單元] SwiftUI動畫與繪圖
#2 第1課 文字動畫(Animation 物件)

4-1a 文字跑馬燈

所謂「跑馬燈(Marquee)」就是一段文字從右到左跑過顯示器的效果,經常會在銀行、醫院、車站等公眾場所看到,對於有限的顯示空間(例如LED顯示器)特別好用。

本課就模擬火車站月台上看到的「南下列車將於1分鐘後進站」當做範例,用SwiftUI寫一個跑馬燈的程式。程式碼如下:
// 4-1a 跑馬燈 Text Animation
// Created by Heman, 2022/02/27
import PlaygroundSupport
import SwiftUI

var 訊息 = "南下列車即將於1分鐘後進站"

struct 跑馬燈: View {
@State var 位移: CGFloat = 450
let 動畫效果 = Animation.linear(duration: 10.0)
var body: some View {
Text(訊息)
.foregroundColor(.purple)
.font(.system(size: 36))
.frame(width: 800.0)
.lineLimit(1)
.offset(x: 位移, y: 0)
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
位移 = -450
}
}
}
}

PlaygroundPage.current.setLiveView(跑馬燈())

這段文字「南下列車即將於1分鐘後進站」之所以能動起來,主要就是利用SwiftUI的「Animation物件」,使用方法分為兩個步驟。

第一,先定義一個Animation物件實例:
let 動畫效果 = Animation.linear(duration: 10.0)

Animation 是物件類型(Type),有多種類型方法(Type method)可用來產出物件實例,這幾種方法分別對應不同的時間曲線,我們採用的 linear(duration: 10.0) 是線性變化,設定變化時間是10秒。

第二步驟是在視圖一出現時(.onAppear),呼叫 withAnimation() 啟用動畫效果,withAnimation() 是一個「全域函式」(global function),意思是函式的有效範圍最大,在程式任何地方均可呼叫,同時也表示 withAnimation() 不屬於任何物件,這在SwiftUI物件庫中是比較少見的。

withAnimation() 需要一個Animation物件實例當作參數,之後再套一層「匿名函式」:
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
位移 = -450
}
}

這裡參數就用剛定義出來的Animation物件實例「動畫效果」,每個Animation物件實例都帶有若干實例方法(Instance method),可用來調整動畫行為,用法與視圖修飾語(View modifier)類似。

- .delay(1.5) — 延遲1.5秒後再開始動畫
- .repeatCount(3) — 反覆動畫3次
- .repeatForever() — 無限次反覆
- .speed(0.5) — 動畫速度降為原來的0.5倍

其中repeatCount()與repeatForever()還可再加一個參數 autoreverses,表示要不要「倒轉」,預設是要倒轉(autoreverses: true)。如果4-1a範例程式用了預設倒轉,文字就會變成左右來回跑,而不是單向從右到左的跑馬燈,所以要加上(autoreverses: false)參數。

在後面跟隨的匿名函式中,我們將狀態變數(@State var)「位移」從原本的 450(單位是畫素「點」)改為-450,這個改變會觸發視圖的狀態變化,從而啟動動畫效果,關鍵的幾行程式碼如下:
@State var 位移: CGFloat = 450
let 動畫效果 = Animation.linear(duration: 10.0)
Text(訊息)
.offset(x: 位移, y: 0)
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
位移 = -450
}
}

當「Text(訊息)」的位移(offset)從螢幕中心點右移450點(x=450)變成左移450點(x=-450)時,withAnimation() 的作用就在這裡啟動。

Animation 物件會用內插法計算10秒鐘內「位移」值的變化,由於我們選擇線性(linear)時間曲線,所以平均每秒鐘要變化90點((450 - (-450)) / 10),一開始是右移450點(x=450)、第1秒後變右移360點、第2秒變右移270點...,每秒往左移動90點。

為了讓動畫順暢,當然不會是一秒鐘才變化一次,我們都知道視覺暫留的原理,電影大約是每秒30幅畫面,有些動作快的遊戲還要求每秒60幅或120幅,這個速度稱為 frame per second (fps)。

如果要達成每秒30幅畫面(30 fps),Animation 每秒鐘需要計算30次,產生30幅視圖畫面,也就是每0.0333秒就計算一次並更新視圖畫面,這樣動畫效果才會順暢。

執行結果如下:


💡 註解
  1. SwiftUI Animation 物件支援的時間曲線可參考下圖,包括線性 linear、緩入 easeIn、緩出 easeOut、緩入緩出 easeInOut、彈簧 spring、可自行定義時間曲線 timingCurve...等等。只有linear是定速,變化最平均,其他非線性的時間曲線,速度都會有所變化,產生更豐富的動畫效果。
    https://www.objc.io/images/blog/2019-09-26-curves_4d7f45c.png
    圖片來源:https://www.objc.io/blog/2019/09/26/swiftui-animation-timing-curves/
  2. 這個範例程式有兩處值得改進,一是「訊息」內容應該是可變化的,特別是「1分鐘」要隨時間而變,最好還能改變字形(如紅色粗體)加以強調。
  3. 第二是「位移」為什麼是固定的450? 更好方法應該是根據「訊息」字串長度,自動計算需要的位移,(預計)到第4課介紹Canvas物件之後,就能自動計算。
  4. Animation 每秒會計算多少幅畫面(fps),實際會視硬體效能而定,當然也與時間曲線、視圖複雜度有關,若效能足夠,會以60fps計算,否則以30fps或更低。
附帶一提:Notion

過去筆者都是用Mac的「備忘錄」打好草稿,再貼到Mobile01網站發布,也試用過Evernote, HackMD 和Medium等雲端工具,最後還是選「備忘錄」最單純,較能專注在文字寫作上。

最近注意到Notion,一試之下,覺得比之前用過的更好用,記筆記或寫文章都超方便,介面簡潔不會干擾思路,而且功能豐富,發布網頁也相當便利,所以決定從本單元開始,直接在Notion上面打草稿,寫完再同步發到Mobile01上。

本單元Notion網址: https://hemanlu.notion.site/4-SwiftUI-f1761468228240228fb8dd7bf55a3b62
#3 4-1b 基本的6種文字動畫

上一節提到產生動畫效果的兩個步驟,第一步是產出Animation的物件實例,第二步是呼叫 withAnimation() 來啟動動畫效果,第二步除了用全域函式withAnimation()之外,也可用視圖修飾語 .animation()。兩個步驟相關參數歸納如下圖:


步驟一要產出Animation物件類型,除了用類型方法(Type method)之外,SwiftUI 也定義了同名的類型屬性(Type property),也就是語法上,用 Animation.linear() 或 Animation.linear 都可以,但後者沒有參數,只能用預設值(如 duration 1秒鐘左右)。

從上圖可以歸納出,要控制Animation動畫行為可利用以下幾個參數,前2個參數在步驟一設定,後4個在步驟二可選擇改變:

1. 時間曲線(timing curve)
2. 變化時間(duration)
3. 是否延遲(delay)
4. 加速或減速(speed)
5. 重複多次或無限循環(repeatCount/repeatForever)
6. 是否倒轉(autoreverses)

若不用循環(repeatCount/repeatForever),則動畫預設只會執行一次。

步驟二用全域函式 withAnimation() 或修飾語 .animation() 啟動動畫效果,兩者語法稍有差異,withAnimation()後面可接匿名函式,.animation() 則只能設定參數,實際用法參考以下範例程式4-1b。

從上面的歸納可以看出,Animation物件跟我們想像中的「動畫片」不完全相同,Animation只是做出我們平常看到的動畫片中,一小段基本的動態效果,甚至可說只是做出兩個視圖畫面之間的銜接過程。

至於視圖畫面實際發生什麼動作,則可透過以下視圖修飾語(View modifier)來完成:

- 位移 .offset()
- 平面旋轉 .rotationEffect()
- 立體旋轉 .rotation3DEffect()
- 模糊 .blur()
- 縮放 .scaleEffect()
- 透明度 .opacity()

以下範例利用這6種視圖修飾語製作動畫效果,程式結構大致相同,只有對Animation步驟一與步驟二的參數稍加改變:
// 4-1b 動態文字 Text Animations
// Created by Heman, 2022/03/02
import PlaygroundSupport
import SwiftUI

struct 移動: View {
var 文字: String
init(_ x: String) { 文字 = x }

@State var 位移: CGFloat = -100
let 動畫效果 = Animation.easeInOut(duration: 1.5)
var body: some View {
Text(文字)
.offset(x: 位移, y: 0)
.animation(動畫效果.repeatForever(), value: 位移)
.onAppear {
位移 = -(位移)
}
}
}

struct 旋轉: View {
var 文字: String
init(_ x: String) { 文字 = x }

@State var 角度: Angle = .degrees(0.0)
let 動畫效果 = Animation.easeIn(duration: 5.3)
var body: some View {
Text(文字)
.rotationEffect(角度)
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
角度 = .degrees(360.0)
}
}
}
}

struct 立體旋轉: View {
var 文字: String
init(_ x: String) { 文字 = x }

@State var 角度: Angle = .degrees(0.0)
let 動畫效果 = Animation.easeOut(duration: 4.7)
var body: some View {
Text(文字)
.rotation3DEffect(角度, axis: (x: 0.0, y: 1.0, z: 0.0))
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
角度 = .degrees(-360.0)
}
}
}
}

struct 模糊: View {
var 文字: String
init(_ x: String) { 文字 = x }

@State var 半徑: CGFloat = 10.0
let 動畫效果 = Animation.spring(response: 0.55, dampingFraction: 0.825, blendDuration: 0)
var body: some View {
Text(文字)
.blur(radius: 半徑)
.onAppear {
withAnimation(動畫效果.repeatForever()) {
半徑 = 0.0
}
}
}
}

struct 縮放: View {
var 文字: String
init(_ x: String) { 文字 = x }

@State var 倍數: CGFloat = 0.1
let 動畫效果 = Animation.default.speed(0.2)
var body: some View {
Text(文字)
.scaleEffect(倍數)
.onAppear {
withAnimation(動畫效果.repeatForever()) {
倍數 = 1.5
}
}
}
}

struct 淡出淡入: View {
var 文字: String
init(_ x: String) { 文字 = x }

@State var 透明度: CGFloat = 0.1
let 動畫效果 = Animation.spring()
var body: some View {
Text(文字)
.opacity(透明度)
.onAppear {
withAnimation(動畫效果.repeatForever()) {
透明度 = 1.2
}
}
}
}

struct 動態文字: View {
var body: some View {
VStack {
Group {
移動("移動")
旋轉("旋轉")
立體旋轉("立體旋轉")
模糊("模糊清晰")
縮放("縮小放大")
淡出淡入("閃爍(淡出淡入)")
}
.frame(width: 300)
.border(Color.red)
.foregroundColor(.purple)
.font(.system(size: 36))
.padding()
}
}
}

PlaygroundPage.current.setLiveView(動態文字())

步驟二若使用.animation(),接受的參數除了Animation物件實例之外,還需要一個參數指定啟動動畫的「狀態變數」,在此範例是「位移」,當「位移」改變就會啟動動畫效果。不過.animation()並無法改變「位移」值(只是監看),也沒有後接匿名函式,所以「位移」值必須在其他地方更改,在此例用 .onAppear 後接的匿名函式更改「位移」值:
.animation(動畫效果.repeatForever(), value: 位移)
.onAppear {
位移 = -(位移)
}

旋轉與立體旋轉用了一個新的物件Angle「角度」,立體旋轉還多一個參數「旋轉軸座標」。旋轉時以螢幕(相對)座標,視圖中心為原點,順時針方向角度為正,範例中從0度旋轉到360度,剛好順時針旋轉一週;立體旋轉軸座標,以通過原點與座標位置的直線為軸,範例中的座標(0, 1, 0)對應Y軸,所以會順著Y軸旋轉。示意圖如下:


從執行結果可以觀察到Animation的第一步驟,用不同的時間曲線產生的效果:
  1. 平移:Animation.easeInOut() 緩入緩出的效果在開始與結束的地方比較慢,像是兩端有阻力的樣子。
  2. 旋轉:Animation.easeIn() 緩入效果在一開始較慢,循環時讓人有暫停的感覺。
  3. 立體旋轉:Animation.easeOut()緩出效果在結束時較慢,可用來強調結束畫面。
  4. 模糊:Animation.spring()的參數比較複雜,從上一節的時間曲線圖可以看出,結尾很乾淨利落,剛好和緩出的效果相反。
  5. 縮放:第一步驟採用Animation.default.speed(0.2),即緩入緩出且速度減為0.2倍。
  6. 閃爍:Animation.spring()用預設的參數,時間無法改變,速度比較快。

執行結果影片如下:

在實際App設計中,同一畫面出現過多動畫是大忌,會有雜亂無章的感覺。人的眼睛對於動的畫面比靜態畫面敏感,所以動靜要適度配合,動畫效果不可太多,而且調整好速度,動畫速度快則注意力強,但容易視覺疲勞。

💡 註解
  1. 同樣這6種動畫效果可以用在任何視圖,並非只有文字適用。
  2. 當然,動畫效果遠不止這6種,但原理都是透過時間與動作變化,設計出無數的動畫效果。
  3. Animation 的時間曲線是採用貝茲曲線(Bezier Curve),在本單元後半段學過貝茲曲線之後,就能更精確地控制動畫行為。
  4. 本節Notion網址: https://hemanlu.notion.site/4-1b-6-fc67323622664bdc8dc2ed4e8d1e768c
CUNNING
金害,前三章還在複習中,新的又來了
雪白西丘斯
別擔心,這個單元預計要寫到6月,有3個月時間可以慢慢學習。
#4 4-1c 雨燕飛翔(組合動畫)

上節提到一個Animation物件事實上只負責一段從A點到B點的動畫效果,相當於動畫片的一個動作分解,可能只有幾秒鐘,若以一部10分鐘的動畫片來算,可能就需要成百上千個Animation物件才能達成。

另一方面,動畫效果也可以用多個基本動畫組合起來,例如球體的「滾動」,同時需要「旋轉」與「移動」兩個動畫物件,當旋轉的周長等於移動的距離時,就顯現出滾動的效果。

以下範例4-1c就是利用基本動畫的組合,來呈現新的動畫效果。先利用上一節「立體旋轉」讓Swift雨燕圖案拍動翅膀,然後再加上「移動」,兩個Animation物件搭配,讓雨燕飛翔起來。
// 4-1c 雨燕飛翔(組合動畫)
// Created by Heman, 2022/03/10
import PlaygroundSupport
import SwiftUI

struct 雨燕飛翔: View {
@State var 位移: CGFloat = -250.0
@State var 旋轉角度: Angle = .degrees(-45.0)
let 移動 = Animation.linear(duration: 2.0)
let 旋轉 = Animation.easeIn(duration: 0.2)

var body: some View {
Image(systemName: "swift")
.font(.system(size: 64))
.foregroundColor(.orange)
.rotation3DEffect(旋轉角度, axis: (x: 1.0, y: 1.0, z: 0.0))
.offset(x: 位移, y: 位移)
.animation(旋轉.repeatForever(), value: 旋轉角度)
.onAppear {
旋轉角度 = -(旋轉角度)
withAnimation(移動.repeatForever(autoreverses: false)) {
位移 = -(位移)
}
}
}
}

PlaygroundPage.current.setLiveView(雨燕飛翔())

這段程式很簡短,但關鍵在於次序,必須先啟動「立體旋轉」,然後再「移動」,如果將兩個Animation物件順序調換,就無法產生飛翔的效果了。


從程式執行結果可以觀察到,視圖「雨燕飛翔」的主體(body)共執行了3次:
  • 第一次是初始狀態(旋轉角度=-45°、位移=-250)

  • 第二次是當「旋轉角度」改變為45°時,啟動第一個動畫 .animation(旋轉.repeatForever(), value: 旋轉角度)

  • 第三次則是「位移」變更為250,啟動第二個動畫 withAnimation(移動.repeatForever(autoreverses: false))


可以試著刪除位移變化 位移 = -(位移),並將初始值改為 @State var 位移: CGFloat = 0.0,就可以觀察到以「立體旋轉」模擬雨燕拍擊翅膀的動畫效果。
#5 第2課 帶屬性文字 AttributedString

在程式設計中,String「字串」是最基本的資料類型之一,不管是輸出錯誤訊息用來除錯,或與使用者對話互動,甚至透過Siri發出語音,都必須借助「字串」,「字串操作」可說是程式設計必備的技巧。

對初學者來說,字串可以操作的方式,可能比想像中還要多樣,常見的字串操作包括:

- 搜尋
- 替換
- 連結
- 分割
- 斷詞斷句
- 大小寫轉換(英文)
- 排序(英文按照字典順序、中文依照筆畫順序)
- 類型轉換(如數字⇄字串、日期⇄字串)
- 逐字操作...等

過去我們最常用的就是兩個字串的「連結」,在Swift中用加號 + 就可以輕鬆連結兩個字串,其他操作則需要使用String的物件方法,例如 .sort() 或 .split() ... 等有數十種之多,未來需要時再以範例說明。

在去(2021)年,Swift 增加一個新的資料類型 AttributedString,中文可稱為「帶屬性字串」,這裡所謂的屬性(attributes),是指文字在SwiftUI視圖中的外觀屬性,包括字體大小、顏色、字距...等,可以視為String「字串」的衍生類型,注意Attributed是被動語態,譯為「帶屬性」。

帶屬性字串與一般字串的主要差別,在於帶屬性字串可個別設定某段文字或甚至某個字的外觀,以下範例4-2a用一串萬國碼(Unicode)符號(麻將牌的「東西南北」),利用AttributedString將其中「南」設為反白,再利用上一課學過的Animation.spring()動畫,做出彈簧伸縮的感覺。
// 4-2a 帶屬性文字 AttributedString
// Created by Heman, 2022/03/19
import PlaygroundSupport
import SwiftUI

let 訊息 = "🀀🀁🀂🀃"
var 帶屬性字串 = AttributedString(訊息)
if let 文字範圍 = 帶屬性字串.range(of: "🀁") {
帶屬性字串[文字範圍].backgroundColor = .red
帶屬性字串[文字範圍].foregroundColor = .white
}

struct 標題: View {
@State var 長寬倍數 = CGSize(width: 1.0, height: 3.0)
let 動畫效果 = Animation.spring(response: 1.0, dampingFraction: 0.2, blendDuration: 0)
var body: some View {
Text(帶屬性字串)
.font(.system(size: 48))
.padding()
.scaleEffect(長寬倍數, anchor: .bottom)
.background(Color.gray.opacity(0.2))
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
長寬倍數 = CGSize(width: 1.0, height: 1.0)
}
}
}
}

PlaygroundPage.current.setLiveView(標題())

將一般字串轉換為帶屬性字串,最簡單的方法就是將字串帶入AttributedString參數中:
let 訊息 = "🀀🀁🀂🀃"
var 帶屬性字串 = AttributedString(訊息)

接下來的操作,則是要先找到改變屬性的文字段落,在此用 .range() 來搜尋,找到文字段落後,直接修改這部份的外觀屬性:
if let 文字範圍 = 帶屬性字串.range(of: "🀁") {
帶屬性字串[文字範圍].backgroundColor = .red
帶屬性字串[文字範圍].foregroundColor = .white
}

注意這裡用 if let,如果 .range() 搜尋到文字,才會回傳「文字範圍」,進入 { } 裡面執行。

在SwiftUI視圖中,帶屬性字串與一般字串都同樣用Text()視圖來顯示文字,適用的視圖修飾語也都一樣:
var body: some View {
Text(帶屬性字串)
.font(.system(size: 48))
.padding()
.background(Color.gray.opacity(0.2))
}

這樣就做出以下效果:


接下來,我們加上動畫的部分:
struct 標題: View {
@State var 長寬倍數 = CGSize(width: 1.0, height: 3.0)
let 動畫效果 = Animation.spring(response: 1.0, dampingFraction: 0.2, blendDuration: 0)
var body: some View {
Text(帶屬性字串)
.scaleEffect(長寬倍數, anchor: .bottom)
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
長寬倍數 = CGSize(width: 1.0, height: 1.0)
}
}
}
}

我們用的動作是「縮放」 .scaleEffect(),最簡單用法是加一個縮放倍數當作參數,如 .scaleEffect(1.5) 放大1.5倍。

除此之外,.scaleEffect() 還可長寬各縮放不同的比例,指定長寬比例可以用 CGSize 物件,CG 是 Core Graphics (核心繪圖)物件庫的縮寫,在本單元後半部會用到很多核心繪圖的物件,都是CG開頭。

.scaleEffect() 除了可指定長寬縮放比例之外,還可以指定縮放的「固定點」(anchor, 或稱「錨點」),預設是從視圖中心點往四周縮放。在這裡我們希望以「文字底部」(bottom)為定點往上縮放。
.scaleEffect(CGSize(width: 1.0, height: 3.0), anchor: .bottom)

這行程式碼會將文字視圖以底部為定點,寬度倍數不變,高度放大3倍。


接下來設定Animation.spring()動畫物件的時間曲線,.spring() 是時間曲線中比較複雜的一個,產生的效果也比較獨特,用3個參數模擬彈簧的運動:

- response: 反應(回彈)時間,時間越短表示彈力越強
- dampingFraction: 阻尼係數,數值越大表示阻力越大,彈簧震盪次數越少
- blendDuration: 計算內插值的時間,影響不大
let 動畫效果 = Animation.spring(response: 1.0, dampingFraction: 0.2, blendDuration: 0)

最後利用 withAnimation() 啟動動畫效果,執行過程如下:

註解
  1. 「東南西北」是非常古老的文字,與天干地支同樣頻繁出現在甲骨文上,表示商朝已有清楚的時間與空間觀念。這四字都是假借字,「東」、「西」本意是裝東西的容器(竹或藤編的籃子),「南」是某種樂器,「北」是兩人背對,假借為四個方向。
  2. scale 若為名詞,是「刻度」「尺度」「格局」之意,若為動詞,則是「縮放」之意,Effect 是「效果」,.scaleEffect() 就是縮放效果的意思。
  3. 將「長寬倍數」的初始值改為 @State var 長寬倍數 = CGSize(width: 0.6, height: 3.0),也就是寬度縮為0.6倍,似乎更接近真實,動畫效果更好。動手試試看!
#6 4-2b 屬性容器 AttributeContainer

上一節提到,帶屬性字串(AttributedString)與一般字串(String)的區別,就是前者的任何一部份(術語稱為”Substring”「子字串」)都可以各自帶不同的外觀屬性。

Swift字串(String)是可以包含「換行字元」“\n”,所以一個字串可能包含很多行,甚至一整篇文章可當作一個字串來處理,其中標題或小標的字體通常要比較大、比較醒目,內文中有些專有名詞或關鍵字想用不同顏色或斜體,某些地名要加上底線等等,在文書編輯中,這稱為「標示」或「標記」,英文為 ”markup”,可當作動詞或名詞。

帶屬性文字AttributedString就可用在這種情境,不管字串長短,都可以對個別子字串標示不同的外觀。

在AttributedString內部,若指定子字串的屬性,會有一個「屬性容器(AttributeContainer)」來記錄個別子字串的屬性,AttributeContainer 也是一個物件類型,注意這裡的Attribute是一般名詞「屬性」,不用被動語態,Container 是裝東西的容器、貨櫃的意思。

本節範例4-2b就利用屬性容器(AttributeContainer)來做一個自製的標示,命名為「上標」。

另外,上一節用到的動畫效果Animation.spring(),除了做出類似伸縮彈簧或是果凍效果之外,也能做出重物墜地的感覺,本節就來試試看。
// 4-2b 屬性容器 AttributeContainer
// Created by Heman, 2022/03/20
// Minor revision (replace .animation -> withAnimation) by Heman, 2023/01/30
import PlaygroundSupport
import SwiftUI

let 雪山輯 = "雪溶後 花香流過司介欄溪的森林"

struct 現代詩選: View {
@State var 詩句 = AttributedString(雪山輯)
@State var 長寬位移 = CGSize(width: 150, height: -300)
let 動畫效果 = Animation.spring(response: 0.6, dampingFraction: 0.4, blendDuration: 0)
var body: some View {
Text(詩句)
.frame(width: 160, height: 160)
.border(Color.red)
.font(.system(size: 30))
.offset(長寬位移)
.onAppear {
if let 範圍 = 詩句.range(of: "雪") {
詩句[範圍].backgroundColor = .brown
詩句[範圍].foregroundColor = .white
詩句[範圍].font = .system(size: 40).bold()
}
var 上標 = AttributeContainer()
上標.baselineOffset = 15
上標.font = .system(size: 24)
上標.foregroundColor = .cyan
if let 範圍 = 詩句.range(of: "花香") {
詩句[範圍].mergeAttributes(上標)
}
if let 範圍 = 詩句.range(of: "司介欄溪") {
詩句[範圍].mergeAttributes(上標)
}
if let 範圍 = 詩句.range(of: "森林") {
詩句[範圍].foregroundColor = .green
詩句[範圍].font = .system(size: 40).bold()
}
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
長寬位移 = CGSize(width: 0, height: 0)
}
}
}
}

PlaygroundPage.current.setLiveView(現代詩選())

我們首先想做出這樣的效果,每個名詞標示不同的外觀:

這是1962年詩人鄭愁予所做的現代詩《浪子麻沁─雪山輯之二》首句。雪山海拔3886m,是台灣第二高峰,「司介欄溪」(或稱司界蘭溪、四季郎溪)是大甲溪支流,在武陵農場南方環山部落附近,過去曾是泰雅族原住民攀登雪山獵場的傳統路線,現在則是爬志佳陽大山(海拔3345m)的登山口。

要為名詞標示不同的外觀,可以仿照上一節的做法:
let 雪山輯 = "雪溶後 花香流過司介欄溪的森林"
var 詩句 = AttributedString(雪山輯)
if let 範圍 = 詩句.range(of: "雪") {
詩句[範圍].backgroundColor = .brown
詩句[範圍].foregroundColor = .white
詩句[範圍].font = .system(size: 40)
}
if let 範圍 = 詩句.range(of: "森林") {
詩句[範圍].foregroundColor = .green
詩句[範圍].font = .system(size: 40).bold()
}

這樣就可以將「雪」標示為反白(前景白、背景棕)且字體放大為40點,「森林」標示為綠色、字體40點、粗體。

除此之外,我們還可以將同一組「屬性」透過「屬性容器(AttributeContainer)」綁在一起,取個名字(如「上標」),外觀屬性包括「底部提高15點、字體縮小為24點、顏色為靛青(cyan, 讀音 /ˈsaɪ.ən/)」,然後將這組屬性標示到「花香」「司介欄溪」上面:
var 上標 = AttributeContainer()
上標.baselineOffset = 15
上標.font = .system(size: 24)
上標.foregroundColor = .cyan
if let 範圍 = 詩句.range(of: "花香") {
詩句[範圍].mergeAttributes(上標)
}
if let 範圍 = 詩句.range(of: "司介欄溪") {
詩句[範圍].mergeAttributes(上標)
}

mergeAttributes() 是子字串的物件方法,用來將屬性容器(參數「上標」)的整組屬性加到自己的屬性容器中,merge 是合併、融合的意思。

標示完文字之後,接下來加入動畫效果:
struct 現代詩選: View {
@State var 長寬位移 = CGSize(width: 150, height: -300)
let 動畫效果 = Animation.spring(response: 0.6, dampingFraction: 0.4, blendDuration: 0)
var body: some View {
Text(詩句)
.frame(width: 160, height: 160)
.offset(長寬位移)
.onAppear {
withAnimation(動畫效果.repeatForever(autoreverses: false)) {
長寬位移 = CGSize(width: 0, height: 0)
}
}
}
}

我們先用視圖修飾語 .frame() 將視圖改為正方形,可想像成一顆石塊,要從空中墜下:

之前用過的「位移」.offset()參數 x, y 座標,也可以改用長寬尺寸 CGSize(),只不過將參數名稱 x, y 改成 width, height,這裡的寬度(width)和高度(height)是有方向性的,與螢幕座標的 x, y 方向一致,所以 CGSize(width: 150, height: -300) 就表示往右150點、往上300點(y軸向下為正),石塊會從螢幕右上方砸下來。

同樣用彈簧動畫效果,與上一節的參數比較起來,上一節的果凍效果回彈較慢(response: 1.0)、阻尼較小(dampingFraction: 0.2),感覺起來比較Q彈。本節是石塊墜地,所以回彈要快要硬(response: 0.6),阻尼稍大(dampingFraction: 0.4):
//4-2a 回彈較慢、阻尼較小
let 動畫效果 = Animation.spring(response: 1.0, dampingFraction: 0.2, blendDuration: 0)
//4-2b 回彈較快、阻尼較大
let 動畫效果 = Animation.spring(response: 0.6, dampingFraction: 0.4, blendDuration: 0)

執行結果如下,如果再配上音效「砰」一聲,就更完美了:


註解
  1. 靛青(cyan, 讀音 /ˈsaɪ.ən/)是一種藍綠色,也是印刷用的四種基本墨色(CMYK)之一,與RGB三原色發光原理不同,墨色是經由吸收與反射光線來呈現色彩,本身並不發光。
  2. CMYK四種基本墨色,靛青(C)染料會吸收紅光(R),反射藍光與綠光;洋紅(M)則吸收綠光(G),反射紅光與藍光;黃色(Y)油墨會吸收藍光(B),反射紅光與綠光,紅綠光混合後變黃光;黑色(K)是紅藍綠通通吸收,不反射任何光線。
#7 4-2c Markdown 標示語言

現在我們已經知道,Swift的帶屬性字串(AttributedString)可以用來「標示」(markup)一篇文章,例如將其中一部分文字(子字串)作為標題、某些標示為內文、某些以斜體表示...等等。被標示過的文字可稱為 “rich text”(富文字 — 有豐富屬性的文字),例如微軟公司的 Word 軟體編輯出來的文件,副檔名可用 .RTF — 代表 Rich Text Format。

不止文書處理軟體,一般的網頁文字內容其實也都有編排過,其文字背後都有「標示」,而且還有專門的「標示語言」來做網頁編排的工作,這個「標示語言」稱為 HTML(HyperText Markup Language, 超文本標示語言),如果檔案副檔名為 .htm 或 .html,就是用 HTML 編排過的文件。

微軟的 .RTF 格式是該公司專有的標示格式,HTML 則是全球資訊網(WWW)的官方標準,除此之外,網路上還流行一種「民間版」的標示語言,稱為 “Markdown”(常用副檔名為 .md),在程式設計圈內非常普遍,例如最大的開放原始碼網站 GitHub,文件幾乎都用 Markdown 格式撰寫。

HTML 與 Markdown 文件格式都非常簡單,簡單到可直接用文字編輯軟體來手工編排,兩者標示方式相當接近,還可透過軟體互相轉換。部分的常見標示範例如下表:
- HTML(以下"<"後面空格要拿掉) Markdown
標題(heading) < h1>文件標題< /h1>
< h2>次標題< /h2>
< h3>小標題< /h3>
# 文件標題
## 次標題
### 小標題
斜體(italic) < i>斜體文字< /i> *斜體文字*
粗體(bold) < b>粗體文字< /b> **粗體文字**
刪除線(strikethrought) < s>應刪除文字< /s> ~~應刪除文字~~
加連結(anchor/link) < a href="網址">連結文字< /a> [連結文字](網址)
加圖片(image) < img src="網址" ,="" alt="圖說"> ![圖說](網址)

【求助】有誰知道如何在Mobile01內容裡面寫 HTML tag 嗎?我用 & lt; 和 & gt; 還是無法顯示角括號的tag,會被render出來。用Notion似乎沒有這個問題 https://hemanlu.notion.site/4-2c-Markdown-7d936d4ca87448b58fb67022098367b3

從上表可以看出,HTML 用一對「角括號」來標示文字;而Markdown則習慣用標點符號 — 這顯然符合程式設計師的喜好。

AttributedString 物件能夠讀入Markdown格式的文件,不過很可惜,目前並非所有Markdown格式都支援,包括標題、底線、圖片等常用標示都暫時不支援,或許未來新版才會加入(還可能支援HTML或RTF等格式),如果發展成熟,用AttributedString物件就能開發出一套文書編輯軟體。

在以下範例4-2c中,用Markdown語法將字串中的地名標示為粗體、「泰耶魯」加上維基百科連結、還有一小句用刪除線測試。

這是我們第一次用「多行字串」,過去我們都是用一對雙引號來包含字串值(String literal),如果字串值很大(跨很多行),可以前後用「連續3個雙引號」來包含字串值,請參考以下範例。
// 4-2c Markdown 標示語言
// Revised by Heman, 2022/03/22
import PlaygroundSupport
import SwiftUI

let 雪山輯 = """
雪溶後 花香流過**司介欄溪**的森林
沿著長長的狹谷 成團的白雲壅著
獵人結伴攀向**司馬達克**去
採菇者領著赤足的婦女
在高寒的**賽蘭酒** 起一叢篝火

修好所有的籬 結新的筏
起得早早的小姑娘 在水邊洗日頭
少年的[泰耶魯](https://zh.wikipedia.org/wiki/%E6%B3%B0%E9%9B%85%E6%97%8F)唱出冬藏的歌
而卻不見了 ~~那著人議論的~~
那浪子麻沁
"""

var 帶屬性字串 = try AttributedString(markdown: 雪山輯, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace))
let 圖片網址 = "https://storage.googleapis.com/opinion-cms-cwg-tw/article/201905/article-5cd90f7b737c2.jpg"

struct 現代詩選: View {
let 動畫效果 = Animation.linear(duration: 12)
@State var 位移: CGFloat = 500
var body: some View {
ZStack {
AsyncImage(url: URL(string: 圖片網址)) { 狀態 in
if let 背景圖片 = 狀態.image {
背景圖片.opacity(0.25)
} else if 狀態.error != nil {
Image(systemName: "xmark.icloud.fill")
.scaleEffect(2)
.foregroundColor(.red)
} else {
ProgressView()
}
}
Text(帶屬性字串)
.font(.system(.title))
.offset(x: 0, y: 位移)
.animation(動畫效果, value: 位移)
.onAppear {
位移 = 0
}
}
}
}

PlaygroundPage.current.setLiveView(現代詩選())

從範例可以看出,Markdown的標示符號直接寫在字串裡面,是資料的一部分,而不像AttributedString的屬性必須寫程式碼才能設定。這樣的好處之一,就是Markdown文件可以由編輯人員另外處理,再透過網路或檔案輸入到Swift程式裡面,程式設計師就不用管文件編排的事情。

要用AttributedString讀入Markdown格式的文字內容,就只需增加一個 markdown 參數:
var 帶屬性字串 = try AttributedString(markdown: 雪山輯)

不過,如果只寫這樣,「換行」會被改成空格,看起來就跟原詩句不一樣了。要維持原詩句的斷行,必須再加一個很長(沒有人記得住)的options 選項參數:
var 帶屬性字串 = try AttributedString(markdown: 雪山輯, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace))

這個 options 的參數值最後的”Whitespace”(空白),意思是包括空格(” ”)、Tab定位鍵(”\t”)以及換行(”\n”),若省略這個參數,預設會將「換行」或「定位鍵(Tab)」都改成「空格」。這是因為在HTML或Markdown語法上,「換行」必須另外標示:HTML 用 < br>,Markdown 則是在行末加兩個空格或單獨一行空白。

由於屬性已經在字串裡面標示好了,導入到AttributedString之後,就直接可以用Text()來顯示, 這次動畫用一個由下而上的跑馬燈來展現新詩內容,這首新詩描寫的是雪山地區環山部落泰雅族人的生活,所以用ZStack在背景加上一張泰雅族少女圖片,其中以AsyncImage抓取網路圖片的程式碼,可參考第3單元範例3-6b


程式執行過程如下:


💡 註解
  1. HTML最初由全球資訊網(WWW)發明人Tim Berners-Lee在1989年所發表,HTML與HTTP, URL三者成為WWW的技術基礎,1995年成為網際網路官方標準(RFC1866),目前最新版本為HTML5。
  2. Markdown標示語言的原創者是美國程式設計師John Gruber,發表於2004年,是針對HTML簡化而來。名稱有點搞怪,別人用markup,它故意叫markdown,當然,字面上「標示起來」或「標示下去」意思是相通的。
  3. 與 “rich text” 相對的名詞是 “plain text”,就是沒有任何外觀屬性的「純文字」。
  4. String literal 的 “literal” 意思是「字面上的」,在Swift中代表「字面值」,也就是不需經過運算的資料值,例如100是整數類型的字面值(Int literal),”東南西北”是字串類型的字面值(String literal)。
  5. 本節照片出自昭和14年(西元1939年)出版的《臺湾国立公園写真集》。
  6. 「司馬達克」、「賽蘭酒」是地名,在司介欄溪沿岸。
  7. 「泰耶魯」即泰雅族,早期由日文「タイヤル(dai-ya-ru)」音譯而來。
  8. 《浪子麻沁 -- 雪山輯之二》全詩共6段42行,本節引用前兩段10行。讀者可將全詩42行加到程式裡再執行一次,並想想看該如何修改程式,才能讓顯示效果更好?
  9. 舊式的中文直式寫法,是由上而下、由右而左,用直式展現古文或詩詞,會更有味道。有辦法將Text()視圖改成直式寫法嗎?
#8 第3課 字串分解

在前一課提到,「字串操作」是程式設計師必備的技能之一,Swift 的字串類型 String 提供數十種物件屬性與方法,用於字串操作,功能相當齊備。例如我們曾經用 .count 取得字串的「長度」(字元數),用 .range() 來搜尋子字串(Substring)。

一個字串最大的長度並沒有限制,只要記憶體容納得了,應該都不會超過 .count 能計算的最大整數;至於最小的字串長度,則是空字串 “”,””.count 等於0。

在Swift內部,一個字串是由0到任意多個「字元(Character)」所組成,「字元」其實也是資料型態的一種,名為 Character,一個字元值也是用雙引號包含起來,例如 “a”, “東”, “😀”, “¥”...等,Swift 字元是以萬國碼(Unicode)為基礎,任何一個萬國碼字元都可以當作字元值。

本課將學習如何將字串分解,包括斷行、斷句與斷字,將字串分解後,才方便對文字進一步處理,不管是要用添加屬性或用產生動畫,都比較有彈性。

4-3a 文字斷行

要分解一個字串,所用的物件方法為 .components(),這是一個物件實例的方法,必須用於一個字串變數或常數(也就是字串實例)。

在以下範例4-3a中,我們用微軟的AI機器人「小冰」,上傳上節課所用的泰雅族少女圖片,讓小冰創作一首新詩,再利用 .components(separatedBy: .newlines) 來斷行,將詩句分解成若干行。
// 4-3a 文字斷行 String.components()
// Revised by Heman, 2022/03/25
import PlaygroundSupport
import SwiftUI

let 小冰創作 = """
天空中有夢人的花瓣飄落
是頑童嬉鬧快樂的消息
可愛的葡萄生長在夢中的世界

希望在太陽的頂巔閃耀
因為人類說我們是朋友
宛若天空一樣的清明

—— 小冰 2022.3.27
"""

let 詩句 = 小冰創作.components(separatedBy: .newlines)
let 行數 = 詩句.count
print(詩句, "共\(行數)行")

struct 現代詩選: View {
@State var 行次: Int = 0
let 定時器 = Timer.publish(every: 1.5, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
ForEach(詩句.indices, id: \.self) { 索引 in
Text(詩句[索引])
.foregroundColor(索引 == 行次 ? .white : .purple)
.background(索引 == 行次 ? Color.cyan : Color.clear)
.font(.system(.title))
}
}
.onReceive(定時器) { _ in
行次 = (行次 + 1) % 行數
if 詩句[行次] == "" {
行次 = (行次 + 1) % 行數
}
}
}
}

PlaygroundPage.current.setLiveView(現代詩選())


程式一開始,小冰創作.components(separatedBy: .newlines) 會傳回一個陣列,陣列元素是一行詩句,原字串「小冰創作」的值並不受影響:
let 詩句 = 小冰創作.components(separatedBy: .newlines)
let 行數 = 詩句.count
print(詩句, "共\(行數)行")

控制台輸出如下,注意陣列中有兩個空字串:
["天空中有夢人的花瓣飄落", "是頑童嬉鬧快樂的消息", "可愛的葡萄生長在夢中的世界", "", "希望在太陽的頂巔閃耀", "因為人類說我們是朋友", "宛若天空一樣的清明", "", "—— 小冰 2022.3.27"] 共9行

接下來,就可以用VStack和ForEach將各行詩句排列顯示出來,跟上一課相比較,VStack 預設每行置中對齊,而上一課用 Text 顯示多行字串時,會向左對齊。本節範例的「Text(詩句[索引])」只顯示一行詩句。
struct 現代詩選: View {
var body: some View {
VStack {
ForEach(詩句.indices, id: \.self) { 索引 in
Text(詩句[索引])
.foregroundColor(.purple)
.font(.system(.title))
}
}
}
}

其次,用 ForEach 顯示陣列時,原本可以用:
// id 重複,會導致執行時控制台出現警告訊息
ForEach(詩句, id:\.self) { 每一行 in
Text(每一行)
}

不過這樣一來,會出現兩行空白行完全一樣,當作 id 會有問題,因此改用索引值當作 id。「詩句.indices」為「詩句」索引值組成的陣列,在此例為 [0...8],共9行,英文 indices 是 index 的複數。

最後再加上動畫效果,我們希望呈現類似歌詞「逐行反白」的效果。這次不用Animation物件也能做動畫效果,因為我們已將字串分解,每一行都是單獨的視圖,因此可直接用視圖修飾語來做變化。

我們用一個之前常用的「定時器」來控制時間,每隔1.5秒換下一行,到最後一行後,再回到第一行:
struct 現代詩選: View {
@State var 行次: Int = 0
let 定時器 = Timer.publish(every: 1.5, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
ForEach(詩句.indices, id: \.self) { 索引 in
Text(詩句[索引])
.foregroundColor(索引 == 行次 ? .white : .purple)
.background(索引 == 行次 ? Color.cyan : Color.clear)
.font(.system(.title))
}
}
.onReceive(定時器) { _ in
行次 = (行次 + 1) % 行數
if 詩句[行次] == "" {
行次 = (行次 + 1) % 行數
}
}
}
}

將目前要顯示反白的「行次」設為狀態變數(@State var),當.onReceive() 收到「定時器」事件時,「行次」加1,百分比符號 % 是除以「行數(9)」的餘數,用來控制「行次」會在 0...8 之間循環。如果該行內容是空字串(空行),則跳到下一行。

反白的做法很簡單,利用判別 「索引 == 行次」,如果「索引」等於目前「行次」,該行要反白,即前景白色(white)、背景靛藍(cyan),否則(其他行)前景紫色(purple)、背景清除(clear)。注意 Color.clear 可想像成透明色,所以會看到預設的背景,深色模式時為黑色,淺色模式時為白色。

執行結果參考以下影片:


💡 註解
  1. 字串分解還有一種進階操作「斷詞」,就是分解出動詞、名詞、形容詞、副詞等不同詞性,是自然語言處理(NLP)的基礎,牽涉到比較複雜的演算法,未來如果有機會寫人工智慧單元時,再加以介紹。
  2. 萬國碼的每個字元存放在記憶體時,位元組長度(byte length)並不是固定的,以Swift 5所用的UTF-8編碼為例,每個英文字元只佔1 byte,但中文字元佔3 bytes;某些字元還允許多個符號組合成而成(如韓文、Emoji等)。有關萬國碼介紹,請參考第1單元第9課
  3. 微軟公司的AI小冰 https://poem.xiaoice.com/
  4. 用AI「創作」出來的新詩,是將前人作品分解、歸類、重組的結果,當然無法跟鄭愁予的作品相比,能夠做到中文語法正確,主詞、動詞、受詞組合無誤,就算不錯了。
  5. 讀者可以動手試試看,「小冰創作.count」與「詩句.count」各是多少。
  6. .onReceive(定時器) { _ in } 的底線(_)代表傳入匿名函式的參數「時間」並未用到,在Swift程式語言中,底線有「可省略」「可忽略」的意思。
#9 4-3b 字串分解:斷句

以往程式要處理中文是相當麻煩的事情,就以斷詞斷句為例,英文單字都是以空格分開,很好分解,標點符號也不多,一個句子一定以大寫開頭、句號(”.”)結尾,句型或語法規則很清楚,幾乎所有程式語言都會內建處理英文字串的相關函式。

中文就複雜多了,古文、散文語法差異很大,標點符號多到不行,行文方向與句讀規則幾乎是隨心所欲,再加上現代社會經常出現中文、英文、數字、全形、半形混合的情況,光是要做字串分解就是一大難題。

還好 Swift 以萬國碼(Unicode)為基礎,萬國碼的設計中,已經考慮各國文字的異同,對每個字元都仔細加以分類、標註屬性,而 Swift 所有處理字串的函式,不只考慮英文,也同時考慮萬國碼所涵蓋的字元,這對我們程式設計處理中文非常有幫助。

上一節分解字串的物件方法 components(separatedBy: 分隔符號),處理中文也非常好用,它會依照參數「分隔符號」將字串分解,傳回分解後的字串陣列。其中參數「分隔符號」是一個字元集合或字元陣列,例如 let 分隔符號 = [”,”, “。”, “;”],就會以中文全形的標點符號(逗號、句號、分號)做為分解字串的分隔符號。

那我們是不是可以定義一個包含所有「中文標點符號」的字元集,來做為斷句的分隔符號呢?其實不需要,因為Swift已經幫我們預設好了。

任何萬國碼字元都可以當作分隔符號,Swift 預先定義了一些「字元集」(CharacterSet),例如上節範例4-3a所用的 .newlines 就是其中之一,注意 newlines 是複數,因為萬國碼裡面當作「換行」的控制字元有好幾個,不只是我們所用的 “\n”。

以下是Swift預先定義,可做為分隔符號的字元集(CharacterSet):
預設「字元集」名稱 說明 包含字元數
.newlines 換行字元集(控制字元) 5個控制字元(0x0A, 0x0D, 0x85, 0x2028, 0x2029)
.whitespaces 各國空白字元集 17個屬於Unicode Zs類別的字元
.whitespacesAndNewlines 空白+換行 22個字元
.punctuationCharacters 各國標點符號 819個Unicode P*類別字元
.lowercaseLetters 各國小寫字母 2,227個Unicode Ll類別字元
.uppercaseLetters 各國大寫字母 1831個Unicode Lu類別字元
.decimalDigits 各國十進位數字 660個Unicode Nd類別字元
.symbols 所有符號 7,741個Unicode S*類別字元


下面範例,我們利用定義好的 .punctuationCharacters 字元集,將一篇文章依照標點符號來斷句,取得一句句不含標點的文字,我們以古典小說「西遊記第一回」來測試看看。
// 4-3b 文字斷句
// Created by Heman, 2022/03/30
import PlaygroundSupport
import SwiftUI

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

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

struct 小說: View {
var 本文: String
init(_ 字串參數: String) { 本文 = 字串參數 }
@State var 顯示內容 = AttributedString("")
@State var 分解結果: [String] = []
@State var 索引 = 0
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
}
.onReceive(定時器) { _ in
顯示內容 = AttributedString(本文)
if let 範圍 = 顯示內容.range(of: 分解結果[索引]) {
print(範圍)
顯示內容[範圍].foregroundColor = .white
顯示內容[範圍].backgroundColor = .cyan
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}
}
}
}

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


程式一開始,我們先測試 .components(separatedBy: .punctuationCharacters) 中文斷句是否正常:
var 斷句分解 = 西遊記第一回.components(separatedBy: .punctuationCharacters)
print("斷句分解結果:\n\(斷句分解)")

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


我們希望分解出各句純文字,但觀察這個結果可發現兩個問題:(1) 中文標點符號雖能有效斷句,但「空白和換行」因為不算標點符號被留下來,如何處理呢?(2) 出現兩個「空字串」,這是因為連續兩個標點符號所造成,如何移除空字串?

第一個問題,我們可以擴充分隔符號的「字元集」,除了標點符號,也增加空白與換行,程式碼如下:
var 斷句分解 = 西遊記第一回.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))

.punctuationCharacters 是一個字元的「集合」,「集合(Set)」與「陣列(Array)」的差別,是陣列元素有前後次序且元素可以重複,而集合不分次序且元素不會重複,集合可以透過 .union 與另一個集合「聯集」起來。

將分隔符號字集加入空白與換行後,斷句結果會增加更多空字串,因為只要連續兩個空格或空行,就會產生空字串,這是第二個要處理的問題。

要去掉陣列中的空字串,可以用陣列另外一個物件方法 .filter { },這個方法尾隨一個匿名函式,符合匿名函式 { } 裡面條件句的元素才會被保留下來,不符合條件句的元素就過濾掉:
斷句分解 = 斷句分解.filter { 句子 in
句子 != ""
}

送入匿名函式的參數是陣列的每個元素「句子」,如果「句子」不是空字串,才保留下來。

測試過文字斷句的方法之後,就可以設計視圖物件,這次我們想透過參數將字串傳入視圖,而不是像上一節在視圖物件中使用全域變數,這部分程式碼如下:
struct 小說: View {
var 本文: String
init(_ 字串參數: String) { 本文 = 字串參數 }
@State var 顯示內容 = AttributedString("")
@State var 分解結果: [String] = []
var body: some View {
Text(顯示內容)
.font(.system(.title3))
.onAppear {
let 初步斷句 = 本文.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))
分解結果 = 初步斷句.filter { 句子 in
句子 != ""
}
}
}
}
PlaygroundPage.current.setLiveView(小說(西遊記第一回))


注意最後一行,我們使用物件實例時,是用「小說(西遊記第一回)」,而不是「小說()」,物件所需要的資料是透過參數傳進去,而不是抓全域變數,這樣的寫法是比較好的。

文字顯示的內容,一開始設為空字串,在 .onAppear 視圖出現的時候,尾隨的匿名函式只會被執行一次,所以我們在匿名函式中,將傳入的資料「本文」進行文字斷句,斷句後的結果指定給「分解結果」,這時「分解結果」是一個字串陣列。

接下來,仿照上一節的方法,用定時器 Timer 來做動畫效果,相關程式碼如下,需要增加一個狀態變數「索引」,指向「分解結果」裡面目前要反白顯示的句子。
struct 小說: View {
@State var 顯示內容 = AttributedString("")
@State var 分解結果: [String] = []
@State var 索引 = 0
let 定時器 = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()
var body: some View {
Text(顯示內容)
.onAppear {
索引 = 分解結果.startIndex
}
.onReceive(定時器) { _ in
顯示內容 = AttributedString(本文)
if let 範圍 = 顯示內容.range(of: 分解結果[索引]) {
print(範圍)
顯示內容[範圍].foregroundColor = .white
顯示內容[範圍].backgroundColor = .cyan
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}
}
}
}

定時器我們設定每隔0.3秒更新一次狀態變數,在 .onReceive 的匿名函式中,如何顯示文章內容呢?

在上一節,我們將斷行後的字串用VStack和ForEach重新組合起來,在這裡就不適用了,因為斷句後很難重新編排,所以我們顯示的是斷句前完整的「本文」:
顯示內容 = AttributedString(本文)


然後用第2課範例4-2a的方法,在「本文」中用 .range() 搜尋斷句後的詞句加以反白。
if let 範圍 = 顯示內容.range(of: 分解結果[索引]) {
顯示內容[範圍].foregroundColor = .white
顯示內容[範圍].backgroundColor = .cyan
}

最後執行結果如下:


從執行結果可以看出,目前程式有個bug,如果搜尋的詞句是前面出現過的,就只會在第一次出現的地方反白,而不是按文章順序讀下去,這是個頭痛的問題,想想看該怎麼解決呢?

💡 註解
  1. 每個萬國碼字元都經過分類標註,類別代號(General Category)可參考維基百科說明 https://en.wikipedia.org/wiki/Template:General_Category_(Unicode)
  2. 英文 punctuation 就是標點符號的意思。
  3. 本節「西遊記第一回」內容取自維基文庫 https://zh.m.wikisource.org/zh-hant/西遊記/第001回
關閉廣告
文章分享
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)

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