每個中學生都學過「尺規作圖」,這是從古希臘「幾何原本」流傳下來的作圖方法,到17世紀笛卡兒發明座標系統後,演變成解析幾何,將空間數值化,成為現代科學的重要基礎,不管是用手機GPS定位,還是要登陸火星,都離不開座標與幾何。
解析幾何也是電腦繪圖的數學基礎,圓與三角形是其中最基本的幾何圖形,本節將學習如何在Canvas畫布上,繪製一個圓與內接正三角形。
// 4-5c 圓與正三角形 Canvas + Path.addArc()
// Created by Heman, 2022/04/21
import PlaygroundSupport
import SwiftUI
struct 圓形: View {
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半徑 = min(寬, 高) / 2
var 畫筆 = Path()
畫筆.addArc(
center: 中心,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.stroke(畫筆, with: .color(.blue), lineWidth: 2)
}
}
}
struct 正三角形: View {
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半徑 = min(寬, 高) / 2
let 弧度 = CGFloat.pi * 120 / 180
let 頂點 = CGPoint(x: 中心.x, y: 中心.y - 半徑)
let 右 = CGPoint(
x: 中心.x + 半徑 * sin(弧度),
y: 中心.y - 半徑 * cos(弧度))
let 左 = CGPoint(
x: 中心.x + 半徑 * sin(弧度*2),
y: 中心.y - 半徑 * cos(弧度*2))
var 畫筆 = Path()
畫筆.addLines([頂點, 右, 左, 頂點])
圖層.stroke(畫筆, with: .color(.blue), lineWidth: 2)
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI] 4-5c 圓與正三角形", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
ZStack {
// 座標軸()
圓形()
正三角形()
} .border(Color.gray)
Text("\(Date())")
.padding()
}
}
PlaygroundPage.current.setLiveView(畫布())
在Canvas中畫弧線的方法 addArc() 是畫筆(Path)中較複雜的一個,需要5個參數:
let 半徑 = min(寬, 高) / 2
畫筆.addArc(
center: 中心, // 圓心位置座標(CGPoint)
radius: 半徑, // 半徑(CGFloat)
startAngle: .zero, // 起始角度(Angle)
endAngle: .degrees(360), // 終止角度(Angle)
clockwise: false // 是否順時針方向(Bool)
)
圓心位置取螢幕中心點,半徑則選寬、高較短者的一半,其中利用全域函式 min() 取參數中的最小值,另一個相對的函式 max() 則是取最大值。
起始角度與終止角度的計算,是以圓心正右方(3點鐘方向)為0°,角度資料類型為 Angle,可用度數或弧度(弳度)來表示,例如 Angle(.degrees(90.0))表示90°,等於弧度𝜋/2,也就是 Angle(.radians(.pi/2)),注意 degrees 和 radians 是用複數名詞。Angle(.zero)表示0°或弧度0,從0°畫到360°就會畫出整個圓。
addArc()最奇特的是最後一個參數,如果設 clockwise: false,實際在螢幕上看到的會是順時針方向,怎麼會這樣?這是因為經過座標轉換,在數學座標中是逆時針方向,到了螢幕座標變成順時針。圖解如下,如果不是畫整個圓,這個參數就會影響畫出的結果:

畫出圓形之後,我們要利用圓周來畫正三角形,因為正三角形的3個頂點到圓心的距離等於半徑,並且三等分整個圓,就可利用三角函數來計算3個頂點的座標,三角函數的計算我們到下一課再詳細說明。

算出三個頂點座標之後,畫三角形就很簡單了,利用 addLines() 可以一口氣畫出多條線段,參數陣列中給4個點,就畫成一個三角形:
var 畫筆 = Path()
畫筆.addLines([頂點, 右, 左, 頂點])
圖層.stroke(畫筆, with: .color(.blue), lineWidth: 2)
最後,再把前一節的座標軸加進來,就形成一個漂亮的座標幾何圖。執行結果如下:

💡 註解
- 英文 arc 是圓弧、弧線、弧形的意思,源自 arch 圓拱建築、拱形。
- radian 是數學專有名詞,簡寫為 rad,譯為弧度或弳度,與度數同樣是測量角度的單位,字源於 radius 半徑。弧度特別適用在三角函數或微積分,常配合 𝜋 來計算,弧度 1𝜋 等於半圓 180°。
4-6a 圓周運動
上一課介紹的Canvas把螢幕當作畫布來作圖,如果再結合第4課的時間軸視圖TimeilneView會如何呢?答案就是更有創意、更精細的動畫。本課利用這兩個功能強大的物件,讓螢幕的畫素動起來。
一開始,仍從簡單的做起,先做一個小球的圓周運動。想法很簡單,先畫一個大圓,再畫一個實心小圓,在大圓圓周上移動,在物理上稱為圓周運動,就像人造衛星繞著地球轉一樣。
上一課已學過如何在Canvas中畫圓,所以問題就是如何讓小球繞圓周運動?答案就是靠時間軸視圖TimelineView,仿照第4課4-4b的做法,在 Canvas 中加上 onChange 修飾語,每次收到時間軸的更新,就移動1°角。
.onChange(of: 時間) { _ in
圓心角 = 圓心角 + 1.0
}
所以接下來的問題,是如何計算小球的座標位置?這就需要用到最基本的三角函數:正弦 sin 與餘弦 cos,要利用三角函數來解問題,秘訣就在於找出「直角」,圓周運動哪裡可以找到直角呢?請參考下圖,半徑剛好是一個直角三角形的斜邊:

我們從正上方(12點鐘方向)為起點,順時針移動,已知中心點座標(x, y)、圓心角𝛉、半徑r,如何求得頂點座標呢?很簡單,從中心點往上移動dy, 再往右移動dx,就可得到頂點座標,而 dy, dx 長度分別等於 cos𝛉, sin𝛉 乘以半徑r(斜邊)。
其中圓心角𝛉要換算成弧度,弧度1𝜋等於180°,所以換算公式為:
弧度 = 角度 * 𝜋 / 180
在 Swift 中,凡是實數類型,包括Float, Double, CGFloat等,都會有一個類型屬性 .pi,代表 𝜋。程式中寫 CGFloat.pi 或 .pi 來引用,
我們讓圓心角每次變化1°,不管移動多少度,頂點座標都可以按照上面公式計算出來。然後以頂點為圓心畫一個實心小圓,經過TimelineView的驅動,小圓自然而然就移動起來了。
完整程式碼如下:
// 4-6a 圓周運動 Canvas + TimelineView
// Created by Heman, 2022/04/23
import PlaygroundSupport
import SwiftUI
struct 圓周運動: View {
let 時間: Date
@State var 圓心角 = 0.0
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半徑 = min(寬, 高) / 2
var 畫筆 = Path()
畫筆.addArc( // 大圓
center: 中心,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.stroke(畫筆, with: .color(.blue), lineWidth: 2)
畫筆 = Path()
let 頂點 = CGPoint(
x: 中心.x + 半徑 * sin(圓心角 * .pi / 180),
y: 中心.y - 半徑 * cos(圓心角 * .pi / 180))
畫筆.move(to: 頂點)
畫筆.addArc( // 小圓
center: 頂點,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.fill(畫筆, with: .color(.cyan))
畫筆.move(to: 頂點)
畫筆.addLine(to: 中心)
圖層.stroke(畫筆, with: .color(.brown), lineWidth: 1)
}
.onChange(of: 時間) { _ in
圓心角 = 圓心角 + 1.0
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI] 4-6a 圓周運動", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
TimelineView(.animation) { 時間參數 in
圓周運動(時間: 時間參數.date)
.border(Color.red)
// .frame(width: 100)
Text("現在時間\(時間參數.date)")
.padding()
}
}
}
PlaygroundPage.current.setLiveView(畫布())
整個程式的重點就在於找出頂點位置的座標,然後以頂點為圓心畫一個小圓,再用「圖層.fill()」將小圓塗成靛青色的實心圓。
畫筆 = Path()
let 頂點 = CGPoint(
x: 中心.x + 半徑 * sin(圓心角 * .pi / 180),
y: 中心.y - 半徑 * cos(圓心角 * .pi / 180))
畫筆.move(to: 頂點)
畫筆.addArc( // 小圓
center: 頂點,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.fill(畫筆, with: .color(.cyan))
每次TimelineView更新,「圓心角」都會有所變化,造成頂點位置移動,小圓自然跟著動,看起來就像繞著圓周運動一樣。實際執行結果如下:
💡 註解
- 正弦函數 sin 念為 sine /saɪn/,讀音跟靛青 cyan /ˈsaɪ.ən/ 幾乎一樣。
- 在筆者的電腦,TimelineView(.animation) 會以每秒60次的速率(60fps)更新,也就是每(1/60)秒移動1°,相當於每秒跑60°,這在物理上稱為「角速度」,通常以弧度計算,即每秒 (1/3)𝜋,角速度只跟角度有關,與半徑、距離無關。跑完一圈需要6秒鐘,稱為此圓周運動的「週期」。
- 範例程式4-6a有個潛在bug,在 onChange 裡面,因為TimelineView 會一直更新時間,所以如果一直執行下去,「圓心角」會不斷變大,最後超過實數最大值而崩潰,這種錯誤稱為 “overflow” (溢出),是蠻嚴重的錯誤。怎樣才能限制「圓心角」在一定範圍,並且維持圓周運動正常呢?
.onChange(of: 時間) { _ in
圓心角 = 圓心角 + 1.0
}
圓周運動可以做出很多漂亮的圖案,例如下圖,陰影的輪廓稱為心形線或心臟線(Cardioid),怎麼做出來的呢?

其實很簡單,令A, B兩點做圓周運動,B點角速度為A的兩倍,畫出所有AB線段,就產生這樣的圖案,參考以下執行結果,試著修改範例4-6a來達成:
如果改變B點的速度(只要與A點不同速即可),還能做出不同的輪廓圖,有興趣的同學動手試試看。
如果有做出來的同學,歡迎將程式碼貼出來跟大家分享。
在第1課4-1c用Animation做雨燕飛翔動畫時,曾提過「滾動」也是一種組合動作:旋轉+平移,而且必須旋轉周長等於位移,否則就變成「滑動」了。
但是單純用Animation物件是無法做出「滾動」的,因為必須知道半徑才能計算周長;若改用Canvas配合TimelineView,不但可以輕鬆實現「滾動」,還能隨意控制速度,想停就停。
滾動由旋轉與平移兩個動作組成,旋轉其實跟上一節圓周運動類似,利用圓心角的改變,移動頂點位置,就能讓圓與三角形旋轉起來(其實圓本身並沒有轉);但是在Canvas中怎麼做平移動作呢?需要移動圓心嗎?還是用之前的 offset() 視圖修飾語?
並不是,在Canvas中有「圖層」專用的平移方法:translateBy()。
translate 英文是「翻譯」的動詞,但在數學上,translate 是指「平移」的動作。數學上的定義,是座標上的一個點,加上一個向量之後移到新位置,就稱為平移。例如座標點(1, 1),往向量(2, 3)平移後的位置,就到(3, 4),所以平移需要一個向量(x, y)當作參數,這個向量稱為位移(displacement)。
我們需要計算的位移向量如下圖,水平位移(x)須剛好等於旋轉周長(即滾動距離),垂直方向不動(y=0):

Canvas 中的「圖層.translateBy()」用法有點特別。過去我們用視圖修飾語時,都是先將視圖做好,再使用 offset() 平移整個視圖;但是在 Canvas 中,必須先執行「圖層.translate()」,執行後的所有繪圖動作才會發生平移作用。
實際語法請參考以下範例程式,除了平移之外,其他部分和前一兩節大同小異:
// 4-6b 滾動 Canvas + TimelineView
// Updated by Heman, 2022/05/03
import PlaygroundSupport
import SwiftUI
struct 滾動: View {
let 時間: Date
@State var 圓心角 = 0.0
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 左中 = CGPoint(x: 0, y: 高/2)
let 半徑 = min(寬, 高) / 2
let 滾動距離 = 半徑 * 圓心角 * .pi / 180
let 位移 = 滾動距離.truncatingRemainder(dividingBy: 寬)
圖層.translateBy(x: 位移, y: 0)
// print(圓心角, 位移, 寬)
var 畫筆 = Path()
畫筆.addArc( // 大圓
center: 左中,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.stroke(畫筆, with: .color(.blue), lineWidth: 2)
let 頂點 = CGPoint( // 正三角形
x: 左中.x + 半徑 * sin(圓心角 * .pi / 180),
y: 左中.y - 半徑 * cos(圓心角 * .pi / 180))
let 右 = CGPoint(
x: 左中.x + 半徑 * sin((圓心角+120) * .pi / 180),
y: 左中.y - 半徑 * cos((圓心角+120) * .pi / 180))
let 左 = CGPoint(
x: 左中.x + 半徑 * sin((圓心角-120) * .pi / 180),
y: 左中.y - 半徑 * cos((圓心角-120) * .pi / 180))
畫筆.addLines([頂點, 右, 左, 頂點])
圖層.stroke(畫筆, with: .color(.brown), lineWidth: 1)
畫筆 = Path()
畫筆.move(to: 頂點)
畫筆.addArc( // 小圓
center: 頂點,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.fill(畫筆, with: .color(.cyan))
}
.onChange(of: 時間) { _ in
圓心角 = 圓心角 + 2.0
if 圓心角 >= 360.0 * 9999 {
圓心角 = 0
}
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI] 4-6b 滾動", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
TimelineView(.animation) { 時間參數 in
滾動(時間: 時間參數.date)
.border(Color.red)
.frame(height: 200)
Text("By Heman.\n\(時間參數.date)")
.multilineTextAlignment(.center)
.font(.callout)
.foregroundColor(.gray)
.padding()
}
}
}
PlaygroundPage.current.setLiveView(畫布())
範例中實際計算位移時,跟上圖稍有不同,多做了一道手續:將滾動距離除以螢幕寬度取其餘數。這是希望到達螢幕右邊界之後,再返回左邊界重新開始,讓位移保持在螢幕寬度以內:
let 滾動距離 = .pi * 半徑 * 圓心角 / 180
let 位移 = 滾動距離.truncatingRemainder(dividingBy: 寬)
圖層.translateBy(x: 位移, y: 0)
但是為什麼不是用 .remainder(),而是改用 .truncatingRemainder() 呢?兩者都是對實數求取餘數,不過 remainder() 的餘數會有正負值,取較接近0者(參考註解);truncatingRemainder() 的餘數只取正值。三種求餘數的方法比較如下:
求餘數 |
適用對象 |
舉例 |
說明 |
% |
整數(Int) |
11 % 3 = 2 |
餘數為正整數 |
remainder() |
浮點數(Float, Double, Date, CGFloat) |
(11.0).remainder(dividingBy: 3.0) = -1.0 |
餘數包含正負(取較近0者) |
truncatingRemainder() |
浮點數(Float, Double, Date, CGFloat) |
(11.0).truncatingRemainder(dividingBy: 3.0) = 2.0 |
餘數均為正數 |
Canvas 內共畫三個圖形,一個大圓、圓內接正三角形以及頂點的小圓,初始位置在螢幕左邊界(左中),而不是在螢幕中央。旋轉角度與位移距離,都是利用 TimelineView 驅動圓心角改變,這次將圓心角更新增加到2°(旋轉週期約3秒),以加快滾動速度,並設定上限最多9999圈後重置。
執行結果如下:
💡 註解
- truncating 原形動詞是 truncate,「割捨」「刪節」「截頭去尾」的意思。
- 電腦是如何做除法呢?方法之一是將除法視為重複的減法,例如 11 ÷ 3,過程是 11 - 3 = 8,因為8 > 3,繼續減,8 - 3 = 5,繼續減,5 - 3 = 2,2 < 3 不夠減了,停止。總共減了3次,所以商為3,餘2。
- 為什麼實數求餘數會有負數?若以 11.5 ÷ 3.1 為例,根據Apple官方文件,remainder()的運算會取 3.1 的「整數倍數」最接近 11.5 者為商,剩下為餘數。如下圖,4倍是最接近被除數11.5,所以商等於4,餘數-0.9。(若剛好在中間怎麼辦?則取偶數倍數為商。)
- truncatingRemainder() 會用「截頭去尾」的方式,較接近整數取餘數(%)的算法,計算整數倍數時不會超過被除數。
如同圓周運動可以衍伸出很多漂亮又有趣的圖案,滾動也是如此。如果將滾動時,頂點的軌跡記錄下來,會是什麼形狀呢?
這種奇特的軌跡稱為「擺線」(cycloid):

不過,本節要利用滾動軌跡來畫另一個軌跡 — 「餘弦函數」(cosine, 簡寫為cos),因為cos是三角函數中,最具代表性的函數,只要熟悉cos函數,其他函數都可以用cos導出。甚至著名的傅立葉轉換,還可將任意波形轉換為餘弦函數的序列組合,換句話說,只用餘弦函數,就能夠表達所有的波形,厲害吧。
要形成餘弦曲線,我們要記錄的是下圖中的「垂足」軌跡,這是頂點至圓心垂直線的垂足,頂點、垂足、圓心形成一個直角三角形,如下圖:

知道如何計算垂足座標之後,怎麼畫出垂足的軌跡呢?應該是從上次垂足座標到目前垂足座標之間畫一線段,但問題是怎麼保存上次垂足座標呢?這個問題困擾筆者好幾天,試過幾種方法都不成功,最後不得不使用一個全域變數來解決。
完整範例程式如下:
// 4-6c 滾動軌跡(餘弦函數) Canvas + TimelineView
// Updated by Heman, 2022/05/05
import PlaygroundSupport
import SwiftUI
var 軌跡: [CGPoint] = []
struct 滾動軌跡: View {
let 時間: Date
@State var 圓心角 = 0.0
var body: some View {
Canvas { 圖層, 尺寸 in
var 軌跡圖層 = 圖層
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 半徑 = min(寬, 高) / 2
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 左中 = CGPoint(x: 0, y: 高/2)
let 滾動距離 = 半徑 * 圓心角 * .pi / 180
let 位移 = 滾動距離.truncatingRemainder(dividingBy: 寬)
// print(位移, 軌跡.count)
圖層.translateBy(x: 位移, y: 0)
var 畫筆 = Path()
畫筆.addArc( // 大圓
center: 左中,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.stroke(畫筆, with: .color(.green), lineWidth: 2)
畫筆 = Path()
let 頂點 = CGPoint(
x: 左中.x + 半徑 * sin(圓心角 * .pi / 180),
y: 左中.y - 半徑 * cos(圓心角 * .pi / 180))
畫筆.addArc( // 頂點小圓
center: 頂點,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
let 垂足 = CGPoint(
x: 左中.x,
y: 左中.y - 半徑 * cos(圓心角 * .pi / 180))
畫筆.addArc( // 垂足小圓
center: 垂足,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.fill(畫筆, with: .color(.cyan))
畫筆 = Path() // 直角三角形
畫筆.addLines([頂點, 垂足, 左中, 頂點])
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)
var 軌跡筆 = Path() // 軌跡(餘弦曲線)
let 垂足座標 = CGPoint(
x: 垂足.x + 位移,
y: 垂足.y)
軌跡 = 軌跡 + [垂足座標]
軌跡筆.addLines(軌跡)
軌跡圖層.stroke(軌跡筆, with: .color(.cyan), lineWidth: 3)
}
.onChange(of: 時間) { _ in
圓心角 = 圓心角 + 2.0
if 圓心角 >= 360 * 99 {
圓心角 = 0
軌跡 = []
}
}
}
}
struct 餘弦函數: View {
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半徑 = min(寬, 高) / 2
var x = 0.0
var 餘弦曲線: [CGPoint] = []
while x < 寬 {
let y = cos(x/半徑) * 半徑
x += 2.0
餘弦曲線 = 餘弦曲線 + [CGPoint(x: x, y: 中心.y - y)]
}
var 畫筆 = Path()
畫筆.addLines(餘弦曲線)
圖層.stroke(畫筆, with: .color(.primary), lineWidth: 1)
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI]4-6c滾動軌跡(餘弦函數)", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
TimelineView(.animation) { 時間參數 in
滾動軌跡(時間: 時間參數.date)
// .background(座標軸())
.frame(height: 200)
.border(Color.red)
Text("By Heman.\n\(時間參數.date)")
.multilineTextAlignment(.center)
.font(.callout)
.foregroundColor(.gray)
.padding()
}
餘弦函數()
// .background(座標軸(y: false))
.frame(height: 200)
.border(Color.orange)
}
}
PlaygroundPage.current.setLiveView(畫布())
在「滾動軌跡」的Canvas中,我們一共畫了5個元素:
1. 大圓
2. 頂點小圓
3. 垂足小圓
4. 直角三角形
5. 軌跡
其中前4個元素都是以螢幕左中點為圓心,根據圓心角的改變,做圓周運動,然後加上「位移」,透過 translateBy() 來平移這4個元素。
至於第5個元素「軌跡」,則是另外一種畫法,必須同時將每次更新的垂足位置與位移記錄下來,不能再套用 translateBy(),因此將軌跡畫在另一層「軌跡圖層」上:
var 軌跡: [CGPoint] = []
struct 滾動軌跡: View {
var body: some View {
Canvas { 圖層, 尺寸 in
var 軌跡圖層 = 圖層
....
var 軌跡筆 = Path() // 軌跡(餘弦曲線)
let 垂足座標 = CGPoint(
x: 垂足.x + 位移,
y: 垂足.y)
軌跡 = 軌跡 + [垂足座標]
軌跡筆.addLines(軌跡)
軌跡圖層.stroke(軌跡筆, with: .color(.cyan), lineWidth: 3)
}
}
}
程式中的「軌跡」,是一個全域變數,存放記錄下來的垂足座標。為什麼必須用全域變數,而不用狀態變數(@State var)呢?
這牽涉到SwiftUI 內部的設計,在Canvas內不能直接修改狀態變數,只能在「事件修飾語」,例如 onChange, onAppear, ...,或是「控制視圖」,如按鈕Button, 開關Toggle, ...等非同步事件的匿名函數中,才能修改狀態變數。
最後,我們另外用cos()函數畫一個餘弦曲線作為比較,因為要和圓周運動軌跡一樣,所以函數的高與週期必須相同,以半徑100的圓所畫出來的軌跡圖,高等於100、週期(2𝜋r)為200𝜋,這樣的cos函數為 y = cos(x/100) * 100,如下圖:

程式用 while 迴圈將整個螢幕寬度每2點的函數值算出來,一一記錄於區域變數「餘弦曲線」內,最後一起顯示在圖層中:
struct 餘弦函數: View {
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半徑 = min(寬, 高) / 2
var x = 0.0
var 餘弦曲線: [CGPoint] = []
while x < 寬 {
let y = cos(x/半徑) * 半徑
x += 2.0
餘弦曲線 = 餘弦曲線 + [CGPoint(x: x, y: 中心.y - y)]
}
var 畫筆 = Path()
畫筆.addLines(餘弦曲線)
圖層.stroke(畫筆, with: .color(.primary), lineWidth: 1)
}
}
}
最後執行結果如下,滾動軌跡與餘弦函數完全一致:
💡 註解
- 「正弦」「餘弦」等三角函數名詞是由明朝末年徐光啟、湯若望所翻譯,徐光啟是中國最早引進西方科學(包括與利瑪竇合譯「幾何原本」前六卷)的人,比日本明治維新開始向歐洲學習早了200多年。
- 目前這個範例程式有個瑕疵,滾動到螢幕最右邊的最後一個軌跡點,會與回到左邊重新開始的第一個點之間畫一直線,影響美觀,想想看有沒有什麼辦法改善。
滾動軌跡除了可以畫出曲線,還能構成非常迷人的圖案,例如下圖為外擺線,這是怎麼做出來的呢?仔細看,圖案裡面還隱藏一個圓與正三角形喔。

做法相當簡單,利用大小兩個相切圓,大圓靜止不動,小圓在外側滾動,取小圓圓周上的一點,滾動的軌跡就會形成外擺線。所以只要能計算出小圓軌跡點的座標,就能用 Canvas + TimelineView 畫出來。
計算方式如下圖。大圓靜止不動,小圓以順時針方向滾動,需要計算兩個點的座標:(1)小圓圓心C₁ → C₁’ 的位移 (2) A點所經過的軌跡。
繞圓滾動就像月球繞地球,有自轉和公轉。與上一節直線(水平)滾動較大的差別,是在於繞圓滾動時,自轉角度除了滾動角度(𝛉₁),還要加上公轉角度(𝛉₂):

除了「位移」與「圓心角(𝛉₁, 𝛉₂)」計算不同之外,其他與上一節的程式碼相差不多,完整範例程式如下:
// 4-6d 外切圓軌跡:外擺線 Canvas + TimelineView
// Updated by Heman, 2022/05/08
import PlaygroundSupport
import SwiftUI
var 軌跡: [CGPoint] = []
struct 圓周運動: View {
let 時間: Date
@State var 小圓心角 = 0.0 // 𝛉₁
@State var 半徑倍數 = 2.36 // r₂/r₁
var body: some View {
Canvas { 圖層, 尺寸 in
var 軌跡圖層 = 圖層
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 最大半徑 = min(寬, 高) / 2
let 小圓半徑 = 最大半徑 / (半徑倍數 + 2)
let 大圓半徑 = 小圓半徑 * 半徑倍數
let 大圓圓心 = 中心
let 小圓圓心 = CGPoint(
x: 大圓圓心.x,
y: 大圓圓心.y - 大圓半徑 - 小圓半徑)
// print(大圓圓心, 大圓半徑)
// print(尺寸)
var 畫筆 = Path()
畫筆.addArc( // 大圓
center: 大圓圓心,
radius: 大圓半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)
// 圖層.fill(畫筆, with: .color(.black))
let 大圓心角 = 小圓心角 * 小圓半徑 / 大圓半徑
let 水平位移 = (大圓半徑 + 小圓半徑) * sin(大圓心角 * .pi / 180)
let 垂直位移 = (大圓半徑 + 小圓半徑) - (大圓半徑 + 小圓半徑) * cos(大圓心角 * .pi / 180)
// print(大圓半徑, 小圓半徑, 圓心角, 大圓心角, 水平位移, 垂直位移)
圖層.translateBy(x: 水平位移, y: 垂直位移)
圖層.opacity = 0.3
畫筆 = Path()
畫筆.addArc( // 小圓
center: 小圓圓心,
radius: 小圓半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
// 圖層.stroke(畫筆, with: .color(.blue), lineWidth: 2)
// 畫筆 = Path()
let 三角頂點 = CGPoint( // 正三角形
x: 小圓圓心.x - 小圓半徑 * sin((小圓心角 + 大圓心角) * .pi / 180),
y: 小圓圓心.y + 小圓半徑 * cos((小圓心角 + 大圓心角) * .pi / 180))
let 右 = CGPoint(
x: 小圓圓心.x - 小圓半徑 * sin(((小圓心角 + 大圓心角) + 120) * .pi / 180),
y: 小圓圓心.y + 小圓半徑 * cos(((小圓心角 + 大圓心角) + 120) * .pi / 180))
let 左 = CGPoint(
x: 小圓圓心.x - 小圓半徑 * sin(((小圓心角 + 大圓心角) - 120) * .pi / 180),
y: 小圓圓心.y + 小圓半徑 * cos(((小圓心角 + 大圓心角) - 120) * .pi / 180))
畫筆.addLines([三角頂點, 右, 左, 三角頂點])
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 2.0)
// print(小圓圓心, 小圓半徑)
圖層.opacity = 1.0
畫筆 = Path()
畫筆.addArc( // 軌跡點
center: 三角頂點,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
圖層.fill(畫筆, with: .color(.cyan))
var 軌跡筆 = Path() // 軌跡(外擺線)
let 軌跡座標 = CGPoint(
x: 三角頂點.x + 水平位移,
y: 三角頂點.y + 垂直位移)
軌跡 = 軌跡 + [軌跡座標]
// print(軌跡.count, 三角頂點, 水平位移, 垂直位移, 軌跡)
軌跡筆.addLines(軌跡)
軌跡圖層.stroke(軌跡筆, with: .color(.cyan), lineWidth: 3)
}
.onChange(of: 時間) { _ in
小圓心角 += 2.0
if 小圓心角 > 360 * 99 {
軌跡 = []
小圓心角 = 0.0
}
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI]4-6d 外切圓軌跡(外擺線)", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
TimelineView(.animation) { 時間參數 in
圓周運動(時間: 時間參數.date)
.border(Color.red)
Text("By Heman.\n\(時間參數.date)")
.multilineTextAlignment(.center)
.font(.callout)
.foregroundColor(.gray)
.padding()
}
}
}
PlaygroundPage.current.setLiveView(畫布())
外擺線圖案會隨著大小兩圓的半徑比例而有所不同,我們先設定為狀態變數,以便未來可以用控制元件加以調整。
@State var 半徑倍數 = 2.36 // r₂/r₁
然後根據上圖的公式計算出大圓心角(𝛉₂),以及小圓圓心的位移向量。這次我們將小圓的內接三角形加以淡化,以免干擾畫出來的圖案,淡化(opacity)的用法與位移類似,都必須在圖層顯示之前先設定:
let 大圓心角 = 小圓心角 * 小圓半徑 / 大圓半徑
let 水平位移 = (大圓半徑 + 小圓半徑) * sin(大圓心角 * .pi / 180)
let 垂直位移 = (大圓半徑 + 小圓半徑) - (大圓半徑 + 小圓半徑) * cos(大圓心角 * .pi / 180)
圖層.translateBy(x: 水平位移, y: 垂直位移)
圖層.opacity = 0.3 // 淡化正三角形
...
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 2.0)
軌跡點與上一節同樣方法,利用全域變數「軌跡」加以儲存,畫在另一個「軌跡圖層」上:
var 軌跡圖層 = 圖層
...
var 軌跡筆 = Path() // 軌跡(外擺線)
let 軌跡座標 = CGPoint(
x: 三角頂點.x + 水平位移,
y: 三角頂點.y + 垂直位移)
軌跡 = 軌跡 + [軌跡座標]
軌跡筆.addLines(軌跡)
軌跡圖層.stroke(軌跡筆, with: .color(.cyan), lineWidth: 3)
程式執行前,最好將Swift Playgrounds的「啟用結果」關閉,如下圖。否則會耗掉大量的記憶體(甚至會讓App退出)。
此外,範例程式(下圖)留了一個小小的bug,一開始畫軌跡時,會有一兩條突兀的線條,不知從何而來,可能是SwiftUI初始化所留下來,要如何才能去除呢?請大家練習debug看看。

以下執行過程是已經deubg修正後的結果,大圓圓心用了漸層顏色,漸層(Gradient)等後面(預計第8課)再詳細說明。
💡 註解
- 有關「擺線」的數學解釋,可參考師大數學系趙文敏教授的文章「幾何學中的海倫」 。
- 內擺線的做法與外擺線類似,只是外切小圓改成內切小圓。
- 滾動的小圓是不是一定要比較小?如果「半徑倍數」小於1會如何?
- 畫的過程中,能否增加一個「暫停」功能?例如用輕觸(Tap)手勢,按一下暫停,再按一下繼續畫,請動手試試看。
- 挑戰題:1982年美國大學入學考試(SAT)數學題:
The radius of Circle A is 1/3 the radius of Circle B.
如果你的答案是3圈,那就錯了。
(A, B兩圓外切)小圓半徑為大圓三分之一
Circle A rolls around Circle B one trip back to its starting point.
小圓繞大圓滾動回到起點
How many times will Circle A revolve in total?
請問小圓共轉動幾圈?
學過幾何的同學都知道,平面上相異兩點決定一直線,所謂「決定」是指經過這兩點的直線恰僅有一條,不會多也不會少。那如果是曲線的話,需要多少點才能決定呢?
像上一課利用滾動軌跡來畫曲線,雖然有趣,但其實是最笨的方法,因為相當耗費電腦記憶體,若我們每轉動1°記錄一個軌跡點(存在陣列中),則轉一圈就需紀錄360點,畫一個餘弦函數通常要轉好幾圈,就需紀錄上千點,若是複雜的外擺線,甚至需要紀錄上萬點才能畫出完整曲線。
所以通常畫曲線不是用軌跡,而是用公式,只要決定係數,就能畫出一條曲線。不過,用公式的缺點是耗費CPU計算時間,因為每一點都須經過函數計算,當然會拖慢速度。
所以,畫曲線比畫直線困難得多,有沒有什麼方法,可以節省記憶體,又不耗費太多CPU時間呢?貝茲曲線就是基於這個目的所設計,它並不是幾百年前流傳下來的公式,而是現代數學家針對電腦化需求所發明的,至今已成為電腦曲線的標準,所有向量繪圖軟體、字型設計、3D模型、動漫遊戲...等,背後都離不開貝茲曲線。
在SwiftUI裡面用畫布Canvas來畫曲線,實際上就是用二階與三階貝茲曲線,二階貝茲曲線需要3個點,除了指定曲線的起點與終點外,還需要額外1個控制點,三階則需要4個點(起點、終點、2個控制點),如下圖:

貝茲曲線的控制點若與起點、終點共線,則會畫出一條直線(線段),若控制點在線外,才會產生曲線,曲線雖不通過控制點,但控制點就像有引力,會吸引曲線靠近。上圖將三階的兩個控制點重合,可以看出與二階的差異,三階曲線會更接近控制點(感覺吸引力較強)。
畫曲線的語法非常簡單,三階(最通用)是 addCurve(),二階是 addQuadCurve(),Quad 是 Quadratic (二次方的)簡寫。
以下是範例程式,可以看出來,在畫曲線之前,程式碼主要工作就是計算起點、終點、控制點等位置座標:
// 4-7a 貝茲曲線
// Created by Heman, 2022/05/09
import PlaygroundSupport
import SwiftUI
struct 二階貝茲曲線: View {
var 說明 = "二階貝茲曲線"
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 左上角 = CGPoint.zero
let 左下角 = CGPoint(x: 0, y: 高)
let 右上角 = CGPoint(x: 寬, y: 0)
var 畫筆 = Path() // 對角線
畫筆.move(to: 左下角)
畫筆.addLine(to: 右上角)
圖層.stroke(畫筆, with: .color(.gray))
畫筆 = Path() // 二階貝茲曲線
畫筆.move(to: 左下角)
畫筆.addQuadCurve(to: 右上角, control: 左上角)
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 3)
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 說明 = "三階貝茲曲線"
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 左上角 = CGPoint.zero
let 左下角 = CGPoint(x: 0, y: 高)
let 右上角 = CGPoint(x: 寬, y: 0)
var 畫筆 = Path() // 對角線
畫筆.move(to: 左下角)
畫筆.addLine(to: 右上角)
圖層.stroke(畫筆, with: .color(.gray))
畫筆 = Path() // 三階貝茲曲線
畫筆.move(to: 左下角)
畫筆.addCurve(to: 右上角, control1: 左上角, control2: 左上角)
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 3)
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-7a 貝茲曲線", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
二階貝茲曲線()
.border(.red)
三階貝茲曲線(說明: "三階貝茲曲線\n(c)2022 Heman Lu")
.border(.red)
}
}
PlaygroundPage.current.setLiveView(畫布())
此範例程式中還介紹一個重要功能:在畫布Canvas中添加文字。
我們曾在第5課一開始,範例4-5a就用過「圖層.draw()」在畫布中顯示文字,不過那時候還不會計算座標,導致圖示與文字重疊在一起,相當不美觀,現在終於要學習改善的方法了。
在畫布(Canvas)中,如何知道文字的大小尺寸,以便精確計算擺放的位置呢?這其實是從第1課跑馬燈開始就存在的問題。
答案是用圖層的方法 resolve() 來解析,resolve 字面是解決、溶解、解析的意思。透過 resolve() 的解析,我們得以獲知文字或圖片的寬高尺寸,進而計算擺放位置的座標。
var 文字 = 圖層.resolve(Text(說明)) // 說明文字
let 文字尺寸 = 文字.measure(in: 尺寸)
let 文字框 = CGRect(
x: 寬 - 文字尺寸.width - 10,
y: 高 - 文字尺寸.height - 10,
width: 文字尺寸.width,
height: 文字尺寸.height)
// print(文字尺寸, 文字框)
文字.shading = .color(.gray)
圖層.draw(文字, in: 文字框)
在這段程式碼中,用圖層解析文字視圖Text(),字體大小是預設值 .body,然後用 measure() 去測量這段文字在某個「尺寸」裡所佔的寬高,為什麼還要指定「尺寸」呢?因為如果視框的尺寸太小,文字會被自動裁減(後面用...表示),得到的文字尺寸就不一樣。
此例先用整個全框尺寸來測量文字的實際大小,然後指定一個可以擺放文字的視框(CGRect),產出 CGRect 物件實例需要指定左上角座標(x, y),以及寬(width)、高(height)。我們希望是擺在畫布右下角,邊緣留10點空白,如下圖:

最後塗上顏色(shading),就能用 draw() 畫出來了。在畫布(Canvas)中都用 shading 來指定色彩(或漸層),而不是用 foregroundColor,shading 的原形 shade 可以當名詞「陰影」或動詞「遮陰」,用在繪畫是指「著色」或「塗色」(以產生明暗變化)。
文字.shading = .color(.gray)
圖層.draw(文字, in: 文字框)
最後執行結果如下圖:

💡 註解
- Bezier (或法文Bézier),讀音為 /ˈbɛz.i.eɪ/,-ier 的結尾是雙母音 /i.eɪ/,近似「耶」,如另一個著名的法文「傅立葉」 Fourier /ˈfʊrieɪ/ 。
- 解析幾何中,二元一次方程式恰可決定一直線,二元二次方程式則是一條「拋物線」,需要指定不共線的三點才能決定一條拋物線。
- 貝茲曲線是1960年代,法國雷諾汽車公司的工程師Pierre Bézier為了讓汽車設計流程電腦化,所發展的演算法。
- 貝茲曲線當然不是唯一畫曲線的方法,任何二次以上多項式或三角函數都能畫曲線,但貝茲曲線是其中應用最廣泛的。
- Quadratic 的字源 quadrate 是四邊形的意思,矩形面積為長X寬,是兩個數乘積,衍伸為 quadratic 是二次方程式的意思。字首 quad- 通常有 4 的含義,如 double 是2倍、triple 3倍、quadruple 4倍。
- CGRect() 用來指定一個畫框或視框位置,Rect 是 Rectangle (矩形/長方形)的簡寫。
還記得在第1課(4-1a)一開始學習用Animation物件產生動畫效果時,需要兩個步驟,第一步是指定一個「時間曲線」來產出Animation物件實例,第二步再用全域函式 withAnimation() 或修飾語 .animation() 來啟動動畫效果。
第一步驟所用的時間曲線,背後就是以「三階貝茲曲線」來定義,根據曲線的不同,產生節奏有快有慢的動畫效果。本節就用畫布 Canvas 與 addCurve() 來畫出各種時間曲線,這需要用到一個簡單的數學技巧:正規化。
所謂正規化(Normalization)或稱一般化,其實就是將某些條件標準化。例如用比例的概念,將(x, y)座標範圍限定在 0 到 1 之間,如 (0.5, 0.5) 代表視框寬、高各一半的位置,也就是中心點。這樣不管畫布實際寬高尺寸多大,都能夠用正規化座標來表示整個畫框範圍,如下圖:

正規化的座標值(x, y)就相當於寬高比例,所以要從正規化座標還原回螢幕座標,算法就是乘以寬、高,並將正規化(數學座標)以「左下角」為原點,轉回以螢幕「左上角」為原點。如上圖所示。
這種方法在向量繪圖中特別有用,因為(x, y)座標值只需指定0到1之間的實數,就能縮放到任何尺寸的螢幕或視框之中。因此不管是 Apple 的系統圖示(SF Symbols)或是網頁SVG向量圖格式,都可使用正規化座標。
三階貝茲曲線也可以正規化,通常將起點設為(0, 0),終點設為(1, 1),所以只要再指定兩個控制點的正規化座標,就可決定一條曲線。例如 Animation.default 用的曲線稱為 easeInOut (緩入緩出),兩個控制點的座標分為是 (0.42, 0.0) 與 (0.58, 1.0),這樣是不是更方便,且通用性更高!
以下範例程式參考某網站的數據,列出12種時間曲線。不同的時間曲線,差別只在於兩個控制點的正規化座標不同而已:
// 4-7b 時間曲線
// Created by Heman, 2022/05/12
import PlaygroundSupport
import SwiftUI
struct 三階貝茲曲線: View {
var unitX1 = 0.0 // unitX = x/寬
var unitY1 = 1.0 // unitY = y/高
var unitX2 = 0.0
var unitY2 = 1.0
var 說明 = "三階貝茲曲線"
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 左上角 = CGPoint.zero
let 左下角 = CGPoint(x: 0, y: 高)
let 右上角 = CGPoint(x: 寬, y: 0)
let 控制點1 = CGPoint( // 以左下角為原點的數學座標
x: 寬 * unitX1, // 轉換為螢幕座標(左上角為原點)
y: 高 - 高 * unitY1)
let 控制點2 = CGPoint(
x: 寬 * unitX2,
y: 高 - 高 * unitY2)
var 畫筆 = Path() // 對角線
畫筆.move(to: 左下角)
畫筆.addLine(to: 右上角)
圖層.stroke(畫筆, with: .color(.gray))
畫筆 = Path()
for 圓心 in [控制點1, 控制點2, 左下角, 右上角] {
畫筆.move(to: 圓心)
畫筆.addArc( // 小圓點
center: 圓心,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.cyan))
畫筆 = Path() // 三階貝茲曲線
畫筆.move(to: 左下角)
畫筆.addCurve(to: 右上角, control1: 控制點1, control2: 控制點2)
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 3)
var 文字 = 圖層.resolve(Text(說明)) // 說明文字
let 文字尺寸 = 文字.measure(in: 尺寸)
let 文字框 = CGRect(
x: 寬 - 文字尺寸.width - 10,
y: 高 - 文字尺寸.height - 10,
width: 文字尺寸.width,
height: 文字尺寸.height)
// print(文字尺寸, 文字框)
文字.shading = .color(.gray)
圖層.draw(文字, in: 文字框)
}
}
}
// Reference to https://easings.net/ for parameters of timing curves.
struct 畫布: View {
let 長寬 = 125.0
var body: some View {
VStack {
Label("[SwiftUI]4-7b 時間曲線", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
HStack {
三階貝茲曲線(unitX1: 0.42, unitY1: 0.0, unitX2: 1.0, unitY2: 1.0, 說明: "easeIn")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeIn
三階貝茲曲線(unitX1: 0.0, unitY1: 0.0, unitX2: 0.58, unitY2: 1.0, 說明: "easeOut")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeOut
三階貝茲曲線(unitX1: 0.42, unitY1: 0.0, unitX2: 0.58, unitY2: 1.0, 說明: "easeInOut")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInOut
}
HStack {
三階貝茲曲線(unitX1: 0.32, unitY1: 0.0, unitX2: 0.67, unitY2: 0.0, 說明: "easeInCubic")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInCubic
三階貝茲曲線(unitX1: 0.33, unitY1: 1.0, unitX2: 0.68, unitY2: 1.0, 說明: "easeOutCubic")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeOutCubic
三階貝茲曲線(unitX1: 0.65, unitY1: 0.0, unitX2: 0.35, unitY2: 1.0, 說明: "easeInOutCubic")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInOutCubic
}
HStack {
三階貝茲曲線(unitX1: 0.64, unitY1: 0.0, unitX2: 0.78, unitY2: 0.0, 說明: "easeInQuint")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInQuint
三階貝茲曲線(unitX1: 0.22, unitY1: 1.0, unitX2: 0.36, unitY2: 1.0, 說明: "easeOutQuint")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeOutQuint
三階貝茲曲線(unitX1: 0.83, unitY1: 0.0, unitX2: 0.17, unitY2: 1.0, 說明: "easeInOutQuint")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInOutQuint
}
HStack {
三階貝茲曲線(unitX1: 0.55, unitY1: 0.0, unitX2: 1.0, unitY2: 0.45, 說明: "easeInCirc")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInCirc
三階貝茲曲線(unitX1: 0.0, unitY1: 0.55, unitX2: 0.45, unitY2: 1.0, 說明: "easeOutCirc")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeOutCirc
三階貝茲曲線(unitX1: 0.85, unitY1: 0.0, unitX2: 0.15, unitY2: 1.0, 說明: "easeInOutCirc")
.frame(width: 長寬, height: 長寬)
.border(.red) // easeInOutCirc
}
}
}
}
// PlaygroundPage.current.setLiveView(三階貝茲曲線(unitX1: 0.0, unitY1: 0.75, unitX2: 0.25, unitY2: 1.0))
PlaygroundPage.current.setLiveView(畫布())
執行結果顯示出來的時間曲線如下圖:

下次如果想自己定義 Animation 時間曲線,應該就知道怎麼做了。如下例:
let 動畫效果 = Animation.timingCurve(0.85, 0, 0.15, 1, duration: 1.0)
💡 註解
- 還記得在第2單元第5課(2-5a)介紹RGB色彩 -- Color(red: 0.5, green: 0.5, blue: 0.5),也是用正規化的數值。
- 時間曲線的X軸代表時間,所以Animation 參數 duration 就是X軸寬度;Y軸則是視圖的狀態變數(如 offset 位移、rotationEffect 旋轉角度、opacity 透明度...等)。
- 正規化的貝茲曲線,起點為(0, 0), 終點為(1, 1),但控制點座標並未限制在 [0, 1] 之間,能否變更控制點座標,將曲線拉出視框之外呢?請測試看看。
- 如果實際需要的貝茲曲線起點、終點不是在對角,而是在水平線上,還能用正規化嗎?
- 範例程式在「畫布」視圖中,連續寫出12個「三階貝茲曲線()」,這是比較直白的寫法,但過於冗長,能不能將這些參數收集到一個陣列,然後用 ForEach 來改寫呢?或可參考第2單元範例2-8b。
- 請仿照 Animation.timingCurve(0.85, 0, 0.15, 1, duration: 1.0) 的參數寫法,將「三階貝茲曲線」前四個參數名稱(unitX1, unitY1, unitX2, unitY2)省略掉,改成以下形式,需要更動哪些程式碼?
三階貝茲曲線(0.85, 0.0, 0.15, 1.0, 說明: "easeInOutCirc")
在本課一開始(4-7a)曾提到,貝茲曲線既不佔記憶體(只需3-4點座標),又能節省CPU時間,這是如何做到的呢?
因為它的運算原理非常簡單,就是在直線(線段)上按比例取值,以二階貝茲曲線為例,從「起點-控制點」,以及「控制點-終點」兩線段中,取同比例 t (0 ≤ t ≤ 1)的A點與B點,將AB連成線段,然後取AB線段的同比例 t 的點,即為軌跡點,將軌跡點連起來,就是貝茲曲線。

根據這個原理,以下範例利用我們上一課(4-6c)所學的動畫技巧,將中間過程線段逐步畫出來,呈現二階貝茲曲線的軌跡,從這個軌跡中可以觀察到,曲線離控制點最近的地方,就在 t=0.5 的線段中點:
// 4-7c 貝茲曲線軌跡(二階)
// Updated by Heman, 2022/05/17
import PlaygroundSupport
import SwiftUI
struct 二階貝茲曲線: View {
let unitX: Double // unitX = x/寬
let unitY: Double // unitY = y/高
let caption: String
let time: Date
init(_ x: Double, _ y: Double, 說明: String, 時間: Date) {
unitX = x
unitY = y
caption = 說明
time = 時間
}
let 刻度 = 0.02
@State var 比例 = 0.0
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 左上角 = CGPoint.zero
let 左下角 = CGPoint(x: 0, y: 高)
let 右上角 = CGPoint(x: 寬, y: 0)
let 控制點 = CGPoint( // 以左下角為原點的數學座標
x: 寬 * unitX, // 轉換為螢幕座標(左上角為原點)
y: 高 - 高 * unitY)
var 畫筆 = Path() // 控制點小圓
畫筆.move(to: 控制點)
畫筆.addArc(
center: 控制點,
radius: 5,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
圖層.fill(畫筆, with: .color(.cyan))
畫筆 = Path() // 對角線
畫筆.move(to: 左下角)
畫筆.addLine(to: 右上角)
圖層.stroke(畫筆, with: .color(.gray))
畫筆 = Path() // 動態貝茲曲線
var t = 0.0
var 軌跡: [CGPoint] = []
while t <= 比例 && t <= 1.01 {
let A點 = CGPoint(
x: 左下角.x + 控制點.x * t - 左下角.x * t,
y: 左下角.y + 控制點.y * t - 左下角.y * t)
let B點 = CGPoint(
x: 控制點.x + 右上角.x * t - 控制點.x * t,
y: 控制點.y + 右上角.y * t - 控制點.y * t)
let 軌跡點 = CGPoint(
x: A點.x + B點.x * t - A點.x * t,
y: A點.y + B點.y * t - A點.y * t)
// print(pA, pB, 軌跡點)
軌跡 = 軌跡 + [軌跡點]
畫筆.addLines([A點, B點])
t += 刻度
}
圖層.stroke(畫筆, with: .color(.green), lineWidth: 1)
// print(軌跡)
畫筆 = Path() // 軌跡
畫筆.addLines(軌跡)
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 5)
var 文字 = 圖層.resolve(Text(caption)) // 說明文字
let 文字尺寸 = 文字.measure(in: 尺寸)
let 文字框 = CGRect(
x: 寬 - 文字尺寸.width - 10,
y: 高 - 文字尺寸.height - 10,
width: 文字尺寸.width,
height: 文字尺寸.height)
// print(文字尺寸, 文字框)
文字.shading = .color(.gray)
圖層.draw(文字, in: 文字框)
}
.onChange(of: time) { _ in
比例 += 刻度
if 比例 > 1.2 { // a little more delay
比例 = 0.0 // 0.0 ~ 1.0
}
}
}
}
struct 畫布: View {
var body: some View {
Label("[SwiftUI]4-7c 貝茲曲線軌跡", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
TimelineView(.periodic(from: .now, by: 0.2)) { 時間參數 in
ZStack {
二階貝茲曲線(0.12, 0.95, 說明: "(c)2022 Heman Lu\n", 時間: 時間參數.date)
二階貝茲曲線(0.95, 0.12, 說明: "\(時間參數.date)", 時間: 時間參數.date)
}
.border(.red)
}
}
}
PlaygroundPage.current.setLiveView(畫布())
畫出來的軌跡圖如下,兩條曲線合起來的輪廓像不像一片漂亮的樹葉:

程式一開始,我們給「二階貝茲曲線」加一個初始化函式 init(),讓前兩個參數名稱可以省略,就像上一節提到 Animation.timingCurve() 的用法。
init(_ x: Double, _ y: Double, 說明: String, 時間: Date) {
unitX = x
unitY = y
caption = 說明
time = 時間
}
// 用法:
二階貝茲曲線(0.95, 0.12, 說明: "\(時間參數.date)", 時間: 時間參數.date)
然後用一個 while 迴圈來計算中間過程的AB線段與軌跡點,這是本節最主要的一段程式碼,計算過程如上圖的說明。注意在while 迴圈條件中,如果按正常寫 t ≤ 1.0,則最後一條線段就不會出現,這與浮點數的運算誤差有關(參考註解2),最後一個軌跡點並不一定會剛好 t == 1.0(即便「刻度」為0.01),改成 t ≤ 1.01 就沒問題了:
var t = 0.0
var 軌跡: [CGPoint] = []
while t <= 比例 && t <= 1.01 {
let A點 = CGPoint(
x: 左下角.x + 控制點.x * t - 左下角.x * t,
y: 左下角.y + 控制點.y * t - 左下角.y * t)
let B點 = CGPoint(
x: 控制點.x + 右上角.x * t - 控制點.x * t,
y: 控制點.y + 右上角.y * t - 控制點.y * t)
let 軌跡點 = CGPoint(
x: A點.x + B點.x * t - A點.x * t,
y: A點.y + B點.y * t - A點.y * t)
// print(pA, pB, 軌跡點)
軌跡 = 軌跡 + [軌跡點]
畫筆.addLines([A點, B點])
t += 刻度
}
這次我們不用 .animation 的時間軸,而是每0.2秒才更新一次,將動畫速度放慢:
TimelineView(.periodic(from: .now, by: 0.2))
最後用ZStack將兩條二階貝茲曲線重疊,來合成一個完整的圖案,動畫過程如下:
💡 註解
- 推薦這支介紹貝茲曲線的影片"The Beauty of Bézier Curves",作者花了不少心思製作動畫解說,做得非常棒:
- 浮點小數的運算誤差是電腦的普遍問題,非Swift才有,因為所有電腦都是用二進位運算,從十進位浮點小數轉換成二進位時,常會變成無理數(無限位小數),例如十進位0.1轉換成二進位就是如此,而 64位元的 Double 類型最多也只能到小數點15位,所以16位以下會被捨棄,產生些微誤差。因此用任何電腦計算「0.01相加100次」,都不會剛好等於1,不信的話,下面範例可試試看:
// Tested by Heman, 2022/05/16
let 𝜋 = 3.14159265358979323846
var x = 0.0
var y = 0.0
for i in 0..<100 {
x += 0.01
y += 0.1
}
print(x, y)
print(𝜋, Double.pi) - 因此在判斷邏輯條件時,帶有小數的浮點數儘量不要用 == 去比較,而是用 小於(<) 或大於(>),並容許一點誤差;整數則沒有這問題。
- while 迴圈這段程式還有個瑕疵,就是每次「比例」更新後,t總是從0開始又計算一遍,也就是前面相同的AB線段與軌跡點會不斷被重複計算,有沒有辦法改進?
內文搜尋

X