上一節【作業1】提到有些線上工具可手動調整貝茲曲線,其實網頁能做到的,Swift 也一樣能做到,這是因為網頁瀏覽器的功能只是電腦或手機的一部分(網頁程式通常用 Javascript 來寫,而 Swift 程式則涵蓋全電腦/全手機功能),所以 Swift 功能涵蓋面一定超過網頁程式。
本節就示範用 SwiftUI 做一個手拉曲線的繪圖板,之後還可以整合到花瓶製作中,在調整貝茲曲線時,同步更新花瓶的造型。
手拉曲線的功能主要利用拖曳手勢,在第4單元4-9c 手動輪播(一) 介紹過,可透過「拖曳參數.translation」來取得滑動位移,位移多少,點座標就跟著移動多少。比較麻煩的地方,其實是螢幕座標與正規化座標之間的轉換。
程式執行的影片如下,貝茲曲線的4個點座標均可調整,移動可超出視圖範圍(讓曲線可充分調整):
這個程式稍長,但不難理解,主要由兩個物件組成:「花瓶曲線」是形狀(紅色部分),取自上一節作業2;「曲線繪圖板」是視圖,用Canvas畫出外框與4個可調整的點座標,覆蓋在花瓶曲線之上(黑色部分)。
下面的程式碼當中,「花瓶曲線」的「曲線」屬性沒有初始化,改由「曲線繪圖板」控制,透過參數送進來,在拖曳手勢調整座標時,同步畫出新的花瓶曲線。
曲線繪圖板有三個區塊:
(1)「最近索引點()」函式計算手勢起始位置最近的點,當作拖曳目標;
(2)「拖曳手勢」負責調整點座標;
(3)「畫布Canvas」畫出外框,並及時更新貝茲曲線4個點的小圓。
以下是完整的程式碼:
// 6-8(補充) 手動調整貝茲曲線
// Created by Heman Lu on 2025/03/22
// Tested with iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
// import RealityKit
// 花瓶右半輪廓曲線
struct 花瓶曲線: Shape {
let 厚度: CGFloat = 0.02 // Normalized to 0~1
let 底座: [CGPoint] = [ // Normalized to 0.0~1.0
CGPoint(x: 0, y: 0),
CGPoint(x: 0.3, y: 0),
CGPoint(x: 0.3, y: 0.02)
]
let 曲線: [CGPoint] // 4個正規化點座標(起點、控制點1、控制點2、終點)
func path(in 尺寸: CGRect) -> Path {
let 寬 = 尺寸.width
let 高 = 尺寸.height
var 畫筆 = Path()
// (1)畫底座
if 底座.count > 1 {
畫筆.addLines(底座)
}
if 曲線.count > 3 {
// (2)畫貝茲曲線
畫筆.addLine(to: 曲線[0])
畫筆.addCurve(
to: 曲線[3],
control1: 曲線[1],
control2: 曲線[2])
// (3) 畫瓶口轉折
畫筆.addLine(to: CGPoint(
x: 曲線[3].x - 厚度,
y: 曲線[3].y))
// (4)畫返回貝茲曲線
let 控制點1 = CGPoint(
x: 曲線[2].x - 厚度,
y: 曲線[2].y)
let 控制點2 = CGPoint(
x: 曲線[1].x - 厚度,
y: 曲線[1].y)
let 終點 = CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y + 厚度)
畫筆.addCurve(to: 終點, control1: 控制點1, control2: 控制點2)
// (5)畫返回底座
畫筆.addLine(to: CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y))
畫筆.addLine(to: CGPoint(
x: 0,
y: 曲線[0].y))
}
畫筆.closeSubpath()
// 恢復原尺寸
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
return 畫筆.applying(縮放矩陣)
}
}
// 手拉曲線
struct 曲線繪圖板: View {
let 繪板寬: CGFloat = 300
let 繪板高: CGFloat = 500
@State var 手拉曲線: [CGPoint] = [ //正規化(數學)座標,原點在左下角
CGPoint(x: 0.3, y: 0.02), // start point
CGPoint(x: 1.0, y: 0.2), // #1 control point
CGPoint(x: 0.01, y: 0.7), // #2 control point
CGPoint(x: 0.1, y: 1) // end point
]
@State var 手勢開始 = false
@State var 上次座標: CGPoint = .zero
@State var 目標索引: Int = 0 // 貝茲曲線的索引(0...3)
func 最近點索引(位置: CGPoint, 點陣列: [CGPoint]) -> Int {
var 索引: Int = 0
var 目前距離: CGFloat = .infinity
for i in 點陣列.indices {
let 兩點距離 = sqrt(
pow(點陣列[i].x - 位置.x, 2) +
pow(點陣列[i].y - 位置.y, 2)
)
if 兩點距離 < 目前距離 {
目前距離 = 兩點距離
索引 = i
}
}
return 索引
}
var 拖曳手勢: some Gesture {
DragGesture(minimumDistance: 5.0)
.onChanged { 參數 in
if 手勢開始 == false {
手勢開始 = true
let 轉換點座標 = 手拉曲線.map { 正規化座標 in
CGPoint(
x: 正規化座標.x * 繪板寬,
y: 繪板高 - 正規化座標.y * 繪板高
)
}
目標索引 = 最近點索引(位置: 參數.location, 點陣列: 轉換點座標)
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
上次座標 = 手拉曲線[目標索引]
}
}
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
手拉曲線[目標索引] = CGPoint(
x: 上次座標.x + 參數.translation.width / 繪板寬,
y: 上次座標.y - 參數.translation.height / 繪板高
)
}
if 目標索引 == 0 {
// To-do: 底座的座標要跟著變化
}
// print(貝茲曲線[目標索引])
}
.onEnded { 參數 in
// print(拖曳位移陣列)
手勢開始 = false
目標索引 = 0
}
}
var body: some View {
ZStack {
花瓶曲線(曲線: 手拉曲線)
.stroke(Color.primary)
.fill(Color.red)
.scaleEffect(y: -1)
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
// let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 上中 = CGPoint(x: 寬/2, y: 0)
let 下中 = CGPoint(x: 寬/2, y: 高)
let 左中 = CGPoint(x: 0, y: 高/2)
let 右中 = CGPoint(x: 寬, y: 高/2)
var 畫筆 = Path()
// (1) 畫外框與十字線
畫筆.addRect(CGRect(origin: .zero, size: 尺寸))
畫筆.move(to: 上中)
畫筆.addLine(to: 下中)
畫筆.move(to: 左中)
畫筆.addLine(to: 右中)
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)
// (2) 畫控制點小圓
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
var 實際座標: [CGPoint] = [] // 將貝茲曲線轉換到數學座標
畫筆 = Path()
for 點座標 in 手拉曲線 {
let 點座標轉換 = CGPoint(
x: 點座標.applying(縮放矩陣).x,
y: 高 - 點座標.applying(縮放矩陣).y
)
實際座標.append(點座標轉換)
// print("座標轉換:\\(點座標轉換)")
畫筆.addArc(
center: 點座標轉換,
radius: 7,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.primary))
// (3) 畫兩條控制點線段
畫筆 = Path()
if 實際座標.count > 3 {
畫筆.move(to: 實際座標[0])
畫筆.addLine(to: 實際座標[1])
畫筆.move(to: 實際座標[2])
畫筆.addLine(to: 實際座標[3])
}
圖層.stroke(畫筆, with: .color(.primary))
}
.gesture(拖曳手勢)
}
.frame(width: 繪板寬, height: 繪板高)
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(曲線繪圖板())