#4 番外篇- SVG to UIBezierPath

Mike
彼得潘的 Swift iOS / Flutter App 開發教室
17 min readMar 18, 2023

目的:SVG to UIBezierPath的過程規範大小位置

擴充UIBezierPath的Function

import UIKit

extension UIBezierPath {
static func calculateBounds(paths: [UIBezierPath]) -> CGRect {
let myPaths = UIBezierPath()
for path in paths {
myPaths.append(path)
}
return (myPaths.bounds)
}

static var ctLogo1: UIBezierPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 183.4, y: 173.5))
path.addCurve(to: CGPoint(x: 155.5, y: 195.7), controlPoint1: CGPoint(x: 175.6, y: 183), controlPoint2: CGPoint(x: 166.3, y: 190.4))
path.addCurve(to: CGPoint(x: 121.1, y: 203.7), controlPoint1: CGPoint(x: 144.7, y: 201), controlPoint2: CGPoint(x: 133.2, y: 203.7))
path.addCurve(to: CGPoint(x: 81, y: 192.6), controlPoint1: CGPoint(x: 106.8, y: 203.7), controlPoint2: CGPoint(x: 93.4, y: 200))
path.addCurve(to: CGPoint(x: 51.7, y: 162.7), controlPoint1: CGPoint(x: 68.6, y: 185.2), controlPoint2: CGPoint(x: 58.8, y: 175.2))
path.addCurve(to: CGPoint(x: 40.9, y: 122.1), controlPoint1: CGPoint(x: 44.5, y: 150.2), controlPoint2: CGPoint(x: 40.9, y: 136.6))
path.addCurve(to: CGPoint(x: 51.7, y: 81.5), controlPoint1: CGPoint(x: 40.9, y: 107.5), controlPoint2: CGPoint(x: 44.5, y: 94))
path.addCurve(to: CGPoint(x: 52.1, y: 80.9), controlPoint1: CGPoint(x: 51.8, y: 81.3), controlPoint2: CGPoint(x: 52, y: 81.1))
path.addLine(to: CGPoint(x: 22.5, y: 51.3))
path.addCurve(to: CGPoint(x: 16.3, y: 60.9), controlPoint1: CGPoint(x: 20.3, y: 54.4), controlPoint2: CGPoint(x: 18.3, y: 57.6))
path.addCurve(to: CGPoint(x: 0, y: 122.1), controlPoint1: CGPoint(x: 5.4, y: 79.6), controlPoint2: CGPoint(x: 0, y: 100))
path.addCurve(to: CGPoint(x: 16.2, y: 183.3), controlPoint1: CGPoint(x: 0, y: 144.2), controlPoint2: CGPoint(x: 5.4, y: 164.6))
path.addCurve(to: CGPoint(x: 60.3, y: 228), controlPoint1: CGPoint(x: 27, y: 202), controlPoint2: CGPoint(x: 41.7, y: 216.9))
path.addCurve(to: CGPoint(x: 121, y: 244.6), controlPoint1: CGPoint(x: 78.9, y: 239.1), controlPoint2: CGPoint(x: 99.1, y: 244.6))
path.addCurve(to: CGPoint(x: 173.5, y: 232.6), controlPoint1: CGPoint(x: 139.5, y: 244.6), controlPoint2: CGPoint(x: 157, y: 240.6))
path.addCurve(to: CGPoint(x: 215.4, y: 199.1), controlPoint1: CGPoint(x: 190, y: 224.6), controlPoint2: CGPoint(x: 204, y: 213.4))
path.addLine(to: CGPoint(x: 183.4, y: 173.5))
path.close()
return path
}

static var ctLogo2: UIBezierPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 170.1, y: 10.4))
path.addCurve(to: CGPoint(x: 121.1, y: 0), controlPoint1: CGPoint(x: 154.4, y: 3.5), controlPoint2: CGPoint(x: 138.1, y: 0))
path.addCurve(to: CGPoint(x: 60.4, y: 16.4), controlPoint1: CGPoint(x: 99.2, y: 0), controlPoint2: CGPoint(x: 79, y: 5.5))
path.addCurve(to: CGPoint(x: 32.8, y: 38.5), controlPoint1: CGPoint(x: 50, y: 22.5), controlPoint2: CGPoint(x: 40.8, y: 29.9))
path.addLine(to: CGPoint(x: 61.7, y: 67.4))
path.addCurve(to: CGPoint(x: 80.8, y: 51.7), controlPoint1: CGPoint(x: 67.2, y: 61.3), controlPoint2: CGPoint(x: 73.5, y: 56))
path.addCurve(to: CGPoint(x: 98.5, y: 43.9), controlPoint1: CGPoint(x: 86.5, y: 48.3), controlPoint2: CGPoint(x: 92.4, y: 45.8))
path.addLine(to: CGPoint(x: 98.5, y: 158.3))
path.addLine(to: CGPoint(x: 98.5, y: 158.7))
path.addCurve(to: CGPoint(x: 100.2, y: 160), controlPoint1: CGPoint(x: 98.9, y: 159), controlPoint2: CGPoint(x: 99.5, y: 159.5))
path.addCurve(to: CGPoint(x: 134.9, y: 160), controlPoint1: CGPoint(x: 105.5, y: 163.7), controlPoint2: CGPoint(x: 119.6, y: 171.4))
path.addCurve(to: CGPoint(x: 136.6, y: 158.7), controlPoint1: CGPoint(x: 135.5, y: 159.6), controlPoint2: CGPoint(x: 136, y: 159.1))
path.addLine(to: CGPoint(x: 136.6, y: 158.3))
path.addLine(to: CGPoint(x: 136.6, y: 42.4))
path.addCurve(to: CGPoint(x: 153.4, y: 47.8), controlPoint1: CGPoint(x: 142.3, y: 43.6), controlPoint2: CGPoint(x: 147.9, y: 45.3))
path.addCurve(to: CGPoint(x: 180.6, y: 67.5), controlPoint1: CGPoint(x: 163.7, y: 52.4), controlPoint2: CGPoint(x: 172.8, y: 59))
path.addLine(to: CGPoint(x: 210.9, y: 40.2))
path.addCurve(to: CGPoint(x: 170.1, y: 10.4), controlPoint1: CGPoint(x: 199.3, y: 27.2), controlPoint2: CGPoint(x: 185.7, y: 17.3))
path.close()
return path
}
}

擴充UIColor的Function

extension UIColor {
static func fromHexString(_ hexString: String, alpha: CGFloat = 1.0) -> UIColor {
let r,g,b: CGFloat
let offset = hexString.hasPrefix("#") ? 1: 0
let start = hexString.index(hexString.startIndex, offsetBy: offset)
let hexColor = String(hexString[start...])
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
r = CGFloat((hexNumber & 0xff0000) >> 16) / 255
g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255
b = CGFloat(hexNumber & 0x0000ff) / 255
return UIColor(red: r, green: g, blue: b, alpha: alpha)
}
return UIColor(red: 0, green: 0, blue: 0, alpha: alpha)
}
}

定義一個自訂 Shape,按比例縮放和變換 bezier 路徑以適應給定的 rect 參數。

import SwiftUI

struct ShapeView: Shape {
let bezier: UIBezierPath
let pathBounds: CGRect

func path(in rect: CGRect) -> Path {
let pointScale = (rect.width >= rect.height)
? max(pathBounds.height, pathBounds.width)
: min(pathBounds.height, pathBounds.width)

let pointTransform = CGAffineTransform(scaleX: 1/pointScale, y: 1/pointScale)
let path = Path(bezier.cgPath).applying(pointTransform)
let multiplier = min(rect.width, rect.height)
let transform = CGAffineTransform(scaleX: multiplier, y: multiplier)
return path.applying(transform)
}
}

pointScale 參數是用來計算路徑縮放比例的參數。在 path(in rect: CGRect) -> Path 方法中,pointScale 參數是通過下列程式碼計算得到的:

let pointScale = (rect.width >= rect.height)
? max(pathBounds.height, pathBounds.width)
: min(pathBounds.height, pathBounds.width)

首先,判斷傳遞給方法的 rect 參數的寬度是否大於或等於其高度,即 rect.width >= rect.height。如果是,則使用 max 函數選擇 pathBounds 的高度和寬度中的最大值作為 pointScale 的值;否則,使用 min 函數選擇 pathBounds 的高度和寬度中的最小值作為 pointScale 的值。

這樣做的原因是,如果 rect 參數的寬度大於或等於其高度,則縮放因子應該為 pathBounds 的高度和寬度中的最大值,以便將形狀縮放到足夠大以填滿 rect 的寬度或高度。如果 rect 參數的高度大於其寬度,則縮放因子應該為 pathBounds 的高度和寬度中的最小值,以便將形狀縮放到足夠小以完全包含在 rect 中。

舉例:

ShapeViewpath(in rect: CGRect) 方法被呼叫時,rect 參數表示要繪製形狀的矩形區域。而 pathBounds 參數則表示形狀路徑的邊界框,也就是形狀的範圍。

假設有一個矩形區域 rect 的寬度為 200,高度為 100,而形狀路徑的邊界框 pathBounds 的寬度為 150,高度為 75。在這個例子中,rect 的寬度大於其高度,因為 200 >= 100。

因此,當 pointScale 被計算時,程式碼會執行以下語句:

let pointScale = max(pathBounds.height, pathBounds.width)

因為 rect 的寬度大於其高度,所以 pointScale 的值將等於 pathBounds 的寬度(150),這個值是形狀路徑邊界框的最大維度。

接下來,程式碼會使用 pointTransform 變換矩陣將形狀路徑縮放到一個固定的尺寸,以便在後續的繪圖中可以正確地呈現形狀。最後,使用 transform 變換矩陣將形狀路徑縮放到適當的大小,使其填滿或包含在 rect 中。

回到ContentView 把ctLogo1. ctLogo2 計算邊界後繪製出來

import SwiftUI


struct ContentView: View {
let pathBounds = UIBezierPath.calculateBounds(paths: [.ctLogo1, .ctLogo2])
var body: some View {
ZStack {
ShapeView(bezier: .ctLogo1, pathBounds: pathBounds)
ShapeView(bezier: .ctLogo2, pathBounds: pathBounds)
}

}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

加入.frame(width: 300, height: 300)

但這樣還不算完全置中,修改如下即可

.frame(width: 300, height: 300 * pathBounds.height/pathBounds.width)

.frame相關知識點可參考

客製化顏色與線條粗細

struct ContentView: View {
let ctColor1 = UIColor.fromHexString("#29ABE2")
let ctColor2 = UIColor.fromHexString("#0A62BC")
let pathBounds = UIBezierPath.calculateBounds(paths: [.ctLogo1, .ctLogo2])
var body: some View {
ZStack {
ShapeView(bezier: .ctLogo1, pathBounds: pathBounds).stroke(Color(ctColor1), lineWidth: 2)
ShapeView(bezier: .ctLogo2, pathBounds: pathBounds).stroke(Color(ctColor2), lineWidth: 2)
ShapeView(bezier: .ctLogo1, pathBounds: pathBounds).fill(Color(ctColor1))
ShapeView(bezier: .ctLogo2, pathBounds: pathBounds).fill(Color(ctColor2))
}
.frame(width: 300, height: 300 * pathBounds.height/pathBounds.width)
}
}

動畫方式呈現程式碼或圖的過程

struct ContentView: View {
@State var endAmount: CGFloat = 0
@State var isFilled = false
let ctColor1 = UIColor.fromHexString("29ABE2")
let ctColor2 = UIColor.fromHexString("#0A62BC")
let pathBounds = UIBezierPath.calculateBounds(paths: [.ctLogo1, .ctLogo2])
var body: some View {
ZStack {
ShapeView(bezier: .ctLogo1, pathBounds: pathBounds)
.trim(from: 0, to: endAmount)
.stroke(Color(ctColor1), lineWidth: 2)
ShapeView(bezier: .ctLogo2, pathBounds: pathBounds)
.trim(from: 0, to: endAmount)
.stroke(Color(ctColor2), lineWidth: 2)
ShapeView(bezier: .ctLogo1, pathBounds: pathBounds)
.fill(Color(ctColor1)).opacity(isFilled ? 1 : 0)
ShapeView(bezier: .ctLogo2, pathBounds: pathBounds)
.fill(Color(ctColor2)).opacity(isFilled ? 1 : 0)
}
.frame(width: 300, height: 300 * pathBounds.height/pathBounds.width)
.onAppear {
withAnimation(.easeInOut(duration: 2)) {
self.endAmount = 1
}
withAnimation(Animation.easeInOut(duration: 2).delay(1.9)) {
self.isFilled = true
}
}
}
}

簡單把參考的影片coding過程逐一紀錄,方便未來用到的時候可以快速上手,但後半段還有蠻多未仔細看的….之後有空再繼續補完。加上目前還沒有完整學過swiftUI 看得實在很累

參考資料
https://www.youtube.com/watch?v=IUpN7sIwaqc

--

--