第5課提過,畫布(Canvas)的底層是一個以向量(Vector)為基礎的平面(2D)繪圖系統,以線條為基本元素,透過畫筆(Path)可以畫出直線、弧線、曲線等各種線條,在第5課4-5b曾列出這些「畫筆」功能,有畫直線的addLine(), addLines()、畫圓弧的addArc(),以及畫曲線addCurve(), addQuadCurve()等。
畫筆描繪的線條輪廓要落到「圖層」中才能顯示出來,就需要用到圖層的物件方法,目前為止已用過的包括 draw(), stroke(), fill(), resolve(), translateBy()等等,其他還有若干未用到的圖層方法,稍作整理如下表:
# | 圖層方法及屬性 | 主要參數/類型 | 用途說明 | 章節 |
1 | stroke() | 畫筆(路徑) | 畫線條(筆觸),指定顏色、線寬 | 4-5b |
2 | fill() | 畫筆(封閉路徑) | 封閉區域(不含邊線)填滿顏色 | 4-6a |
3 | draw() | 文字、圖片 | 顯示文字、圖片或解析過的視圖 | 4-5a |
4 | drawLayer() | 匿名函式 | 新增(上層)圖層 | 4-7d |
5 | clip() | 畫筆(封閉路徑) | 新增顯示遮罩(任何形狀) | - |
6 | clipBoundingRect | 視框(CGRect) | 指定遮罩(剪裁)視框範圍 | - |
7 | resolve() | 文字、圖片 | 解析文字或圖片(尺寸) | 4-7a |
8 | resolveSymbol() | 視圖 | 顯示其他視圖物件 | - |
9 | translatBy() | 位移向量(x, y) | 圖層位移 | 4-6b |
10 | scaleBy() | 寬、高(x, y) | 圖層縮放 | 4-9a |
11 | rotate() | 角度(Angle) | 圖層旋轉 | - |
12 | addFilter() | 濾鏡選項 | 新增圖層濾鏡(彩度、灰階、模糊...) | - |
13 | opacity | 實數(0.0 ~ 1.0) | 圖層的透明度 | 4-6d |
14 | transform | 變換矩陣 (CGAffineTransform) |
圖層的(仿射)變換矩陣 | 4-7d |
15 | blendMode | 混合選項 | 圖層相疊時的(顏色)混合模式 | - |
這其中功能最強大的,應該是 transform,transform 是一個屬性(變數),而不是方法(函式),資料類型是CGAffineTransform,CG 是 Core Graphics 縮寫,Affine Transform 數學上稱為「仿射變換」,所以transform 可稱為「仿射變換」或是「變換矩陣」。
「仿射變換」是利用數學「變換矩陣」對畫筆繪出的圖形加以操作,透過圖層的 transform,可同時做到平移、旋轉、縮放、鏡像等效果。
例如,在上一節用兩條二階貝茲曲線畫出一個葉片的形狀,我們就可用「鏡像」複製成對稱葉片,再用縮放+平移,將這對葉片加以複製並逐漸縮小,如下圖,這種葉片排列方式在植物學上稱為「對生葉序」。

要產生「對生葉序」,須同時操作鏡像、縮放、平移、旋轉,若是用 translateBy(), scaleBy(), rotate() 逐步操作,會相當麻煩,改成 transform 就可一次搞定,只要設定好矩陣內容,圖形就能自動轉換。完整範例程式如下:
// 4-7d 葉片--變換矩陣(CGAffineTransform)
// Created by Heman, 2022/05/20
import PlaygroundSupport
import SwiftUI
struct 葉片: View {
var unitX = 0.05 // unitX = x/寬
var unitY = 0.05 // unitY = y/高
var 說明 = "變換矩陣(CGAffineTransform)\n(c)2022 Heman Lu"
var body: some View {
Canvas { 圖層, 尺寸 in
let 文字圖層 = 圖層
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 左上角 = CGPoint.zero
let 右下角 = CGPoint(x: 寬, y: 高)
let 控制點1 = CGPoint( // 以左下角為原點的數學座標
x: 寬 * unitX, // 轉換為螢幕座標(左上角為原點)
y: 高 - 高 * unitY)
let 控制點2 = CGPoint(
x: 寬 * (1.0 - unitX),
y: 高 - 高 * (1.0 - unitY))
// print(尺寸, 控制點1, 控制點2)
var 畫筆 = Path() // 葉片:兩條二階貝茲曲線
畫筆.move(to: 左上角)
畫筆.addQuadCurve(to: 右下角, control: 控制點1)
畫筆.addQuadCurve(to: 左上角, control: 控制點2)
let 縮放 = 0.2
let 角度 = -CGFloat.pi/4
let 層次 = 7.0
for i in stride(from: 1.4, to: 層次, by: 0.5) {
let 縮放矩陣 = CGAffineTransform(
a: 縮放/i, b: 0,
c: 0, d: 縮放/i,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(角度), b:sin(角度),
c:-sin(角度), d: cos(角度),
tx: 寬-寬/i, ty: 高/i)
let 水平鏡像 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
圖層.drawLayer { 新圖層 in
新圖層.transform = 水平鏡像.concatenating(縮放矩陣).concatenating(旋轉位移)
新圖層.fill(畫筆, with: .color(.green))
}
圖層.transform = 縮放矩陣.concatenating(旋轉位移)
圖層.fill(畫筆, with: .color(.green))
}
var 文字 = 文字圖層.resolve(Text(說明)) // 說明文字
let 文字尺寸 = 文字.measure(in: 尺寸)
let 文字框 = CGRect(
x: 寬 - 文字尺寸.width - 10,
y: 高 - 文字尺寸.height - 10,
width: 文字尺寸.width,
height: 文字尺寸.height)
// print(文字尺寸, 文字框)
文字.shading = .color(.gray)
文字圖層.draw(文字, in: 文字框)
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI]4-7d 平移旋轉縮放", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
葉片()
.border(.red)
}
}
PlaygroundPage.current.setLiveView(畫布())
transform 背後的數學用到矩陣乘法,高中雖然已教過,但如果學過線性代數會更清楚,在此無法多做說明,可參考註解一及註解二。
根據Apple原廠文件,transform 的資料類型 CGAffineTransform 格式如下,共有a, b, c, d, tx, ty等6個參數,a, b, c, d 對應二維座標(x, y)的變換,tx, ty 對應位移向量:

據此(並參考註解一),我們定義三個變換矩陣,注意其中「縮放矩陣」和「水平鏡像」的結構是一樣的,也就是說,若將圖案的垂直(y值)縮放「-1倍」,就會產生X軸的鏡像:
let 縮放矩陣 = CGAffineTransform(
a: 縮放/i, b: 0,
c: 0, d: 縮放/i,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(角度), b:sin(角度),
c:-sin(角度), d: cos(角度),
tx: 寬-寬/i, ty: 高/i)
let 水平鏡像 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
仿射變換 transform 的操作都是以「原點」為軸心,因此我們先以左上角的螢幕原點為中心,畫出從螢幕左上角到右下角的葉片:
var 畫筆 = Path() // 葉片:兩條二階貝茲曲線
畫筆.move(to: 左上角)
畫筆.addQuadCurve(to: 右下角, control: 控制點1)
畫筆.addQuadCurve(to: 左上角, control: 控制點2)
再用一個新圖層,鏡像複製一片對稱葉片:
圖層.drawLayer { 新圖層 in
新圖層.transform = 水平鏡像
新圖層.fill(畫筆, with: .color(.green))
}
然後利用 for 迴圈控制變換矩陣的參數,畫出逐漸縮小的對稱葉片,一半葉片畫在原「圖層」,另一半鏡射葉片畫在「新圖層」:
let 縮放 = 0.2
let 角度 = -CGFloat.pi/4
let 層次 = 7.0
for i in stride(from: 1.4, to: 層次, by: 0.5) {
let 縮放矩陣 = CGAffineTransform(
a: 縮放/i, b: 0,
c: 0, d: 縮放/i,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(角度), b:sin(角度),
c:-sin(角度), d: cos(角度),
tx: 寬-寬/i, ty: 高/i)
let 水平鏡像 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
圖層.drawLayer { 新圖層 in
新圖層.transform = 水平鏡像.concatenating(縮放矩陣).concatenating(旋轉位移)
新圖層.fill(畫筆, with: .color(.green))
}
圖層.transform = 縮放矩陣.concatenating(旋轉位移)
圖層.fill(畫筆, with: .color(.green))
}
在此,for 迴圈的變數範圍用全域函式 stride() 來計算,可用實數來當間距,對向量繪圖比較方便,也可避免上一節處理浮點小數誤差的問題。stride 是名詞也是動詞,有跨步、邁步、步伐、進展的意思。
for i in stride(from: 1.4, to: 層次, by: 0.5)
for 迴圈的變數 i 會從1.4開始,每次增加0.5,直到大於或等於「層次」(7.0)為止,共執行12次迴圈,產生12對葉片。這12對葉片,都是從原來兩條貝茲曲線所畫的葉片「仿射變換」而來,如下圖,原始葉片為虛線部分:

注意仿射變換 transform 的前後順序是有關係的,上面的:
圖層.transform = 縮放矩陣.concatenating(旋轉位移)
會先縮放,再旋轉位移。若改成:
圖層.transform = 旋轉位移.concatenating(縮放矩陣)
則變成先旋轉位移再縮放,將會出現完全不同的結果。
concatenating 是「前後相連」的意思,原形動詞為concatenate,字串或陣列的加法(+)基本上也是 concatenate,例如 “you” + “Tube” == “youTube”,原文就稱為 String concatenation,是有前後順序的。
💡 註解
- 變換矩陣的數學,可參考北一女中蘇鴻傑老師的簡介:平面上基本的線性變換:旋轉、鏡射、伸縮、推移,數學表達方式與原廠文件稍有不同,但兩者都正確。
- 有關仿射變換的數學說明,請參考交大周志成老師「線代啟示錄」。
- CGAffineTransform 官方文件連結如下:https://developer.apple.com/documentation/coregraphics/cgaffinetransform
- 矩陣運算對2D/3D電腦繪圖非常重要,也是人工智慧(AI)背後的數學基礎,在工程各領域應用非常廣。
- 現在電腦的繪圖晶片(GPU)都有硬體矩陣運算的功能,速度比軟體運算快很多,而且可以多核心並行運算,因此使用矩陣變換會讓圖形運算速度加快。
- 仿射變換的功能,除了畫布圖層之外,在畫筆(Path)或視圖(View)也都有,語法稍有差異,但功能完全相同,畢竟底層都同樣來自Core Graphics,至於在哪個地方用比較好,就須因地制宜,沒有成規。
- for 迴圈為什麼不從1.0開始?縮放為什麼是0.2?角度改成 -CGFloat.pi/3 可以嗎?這些都可以動手試試看。
- 使用 stride() 不但可以用實數當間距,還可以往下數,不過要注意「邊界條件」,最後一個值(to: 末值)是不會用到的,也就是說若迴圈變數等於末值,會停止迴圈,並不會採用末值。參考以下測試:
// Tested by Heman, 2022/08/27
// https://developer.apple.com/documentation/swift/stride(from:to:by:)
print("第一段")
for i in stride(from: 0.0, to: 10.0, by: 2.0) {
print(i)
}
print("第二段")
for i in stride(from: 100.0, to: -100.0, by: -20.0) {
print(i)
}