繪圖動畫基礎-文章篇
-
帶屬性文字 AttributedString
在程式設計中,String「字串」是最基本的資料類型之一,不管是輸出錯誤訊息用來除錯,或與使用者對話互動,甚至透過Siri發出語音,都必須借助「字串」,「字串操作」可說是程式設計必備的技巧。
對初學者來說,字串可以操作的方式,可能比想像中還要多樣,常見的字串操作包括:
- 搜尋
- 替換
- 連結
- 分割
- 斷詞斷句
- 大小寫轉換(英文)
- 排序(英文按照字典順序、中文依照筆畫順序)
- 類型轉換(如數字⇄字串、日期⇄字串)
- 逐字操作...等過去我們最常用的就是兩個字串的「連結」,在Swift中用加號 + 就可以輕鬆連結兩個字串,其他操作則需要使用String的物件方法,例如 .sort() 或 .split() ... 等有數十種之多,未來需要時再以範例說明。
在 2021 年,Swift 增加一個新的資料類型 AttributedString,中文可稱為「帶屬性字串」,這裡所謂的屬性(attributes),是指文字在SwiftUI視圖中的外觀屬性,包括字體大小、顏色、字距...等,可以視為String「字串」的衍生類型,注意Attributed是被動語態,譯為「帶屬性」。
帶屬性字串與一般字串的主要差別,在於帶屬性字串可個別設定某段文字或甚至某個字的外觀,以下範例4-2a用一串萬國碼(Unicode)符號(麻將牌的「東西南北」),利用AttributedString將其中「南」設為反白,再利用上一課學過的Animation.spring()動畫,做出彈簧伸縮的感覺。
操作步驟如下:
選取畫面左方的「+」號新增電子書頁面。
將新增的電子書面頁命名為「(27)SwiftUI動畫-2文章篇」。
在「Main」模組中撰寫程式:
// 4-2a 帶屬性文字 AttributedString // Created by Philip, Heman, Jean 2022/03/19 // Revised by Jean 2025/01/20 import PlaygroundSupport import SwiftUI let 訊息 = "🀀🀁🀂🀃" var 帶屬性字串 = AttributedString(訊息) if let 文字範圍 = 帶屬性字串.range(of: "🀁") { 帶屬性字串[文字範圍].backgroundColor = .red 帶屬性字串[文字範圍].foregroundColor = .white // 帶屬性字串[文字範圍].font = .system(size: 48).bold() } struct 標題: View { @State var 長寬倍數 = CGSize(width: 0.6, 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)
屬性容器 AttributeContainer
上一節提到,帶屬性字串(AttributedString)與一般字串(String)的區別,就是前者的任何一部份(術語稱為”Substring”「子字串」)都可以各自帶不同的外觀屬性。
Swift字串(String)是可以包含「換行字元」“\n”,所以一個字串可能包含很多行,甚至一整篇文章可當作一個字串來處理,其中標題或小標的字體通常要比較大、比較醒目,內文中有些專有名詞或關鍵字想用不同顏色或斜體,某些地名要加上底線等等,在文書編輯中,這稱為「標示」或「標記」,英文為 ”markup”,可當作動詞或名詞。
帶屬性文字AttributedString就可用在這種情境,不管字串長短,都可以對個別子字串標示不同的外觀。
在AttributedString內部,若指定子字串的屬性,會有一個「屬性容器(AttributeContainer)」來記錄個別子字串的屬性,AttributeContainer 也是一個物件類型,注意這裡的Attribute是一般名詞「屬性」,不用被動語態,Container 是裝東西的容器、貨櫃的意思。本節範例4-2b就利用AttributeContainer來做一個自製的標示,名稱命名為「上標」。
另外,上一節用到的動畫效果Animation.spring(),除了做出類似伸縮彈簧或是果凍效果之外,也能做出重物墜地的感覺,本節就來試試看。
在「Main」模組中撰寫程式:
// 4-2b 屬性容器 AttributeContainer // Created by by Philip, Heman, Jean, 2022/03/20 // Minor revision (replace .animation -> withAnimation) by Philip, Heman, Jean 2023/01/30 // Revised by Jean 2025/01/20 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)
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則習慣用標點符號 — 這顯然符合程式設計師的喜好。
AttributedString 物件能夠讀入Markdown格式的文件,不過很可惜,目前並非所有Markdown格式都支援,包括標題、底線、圖片等常用標示都暫時不支援,或許未來新版才會加入(還可能支援HTML或RTF等格式),如果發展成熟,用AttributedString物件就能開發出一套文書編輯軟體。
在以下範例4-2c中,用Markdown語法將字串中的地名標示為粗體、「泰耶魯」加上維基百科連結、還有一小句用刪除線測試。
這是我們第一次用「多行字串」,過去我們都是用一對雙引號來包含字串值(String literal),如果字串值很大(跨很多行),可以前後用「連續3個雙引號」來包含字串值,請參考以下範例。
// 4-2c Markdown 標示語言 // Created by Philip, Heman, Jean 2022/03/22 // Revised by Jean 2025/01/21 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://obs.line-scdn.net/0h8Wcq-z5xZ3heCHCa4y0YL2Zeawltbn1xfGouHnwIakkhJCB-a2g0G3wPbVR6PXIrfm4pTilbOxwkOSkqZw/w1200" 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。