第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 | 3階變換矩陣 | ||
(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 的操作都是以「原點」為軸心,因此我們先以左上角的螢幕原點為中心,畫出從螢幕左上角到右下角的葉片: