使用 SwiftUI 預覽 UIBezierPath 的繪圖結果

新版有更好的方法,使用 #Preview 預覽 UIBezierPath 的繪圖結果。

以下是舊版的做法。

利用 SwiftUI 的 Path 繪圖比 UIKit 的 UIBezierPath 容易許多,因為我們可以一邊畫,一邊透過 SwiftUI 的預覽畫面觀看繪製的結果。其實 UIBezierPath 也可享用 SwiftUI 方便的預覽功能,接下來就讓我們一步步示範透過 SwiftUI 預覽 UIBezierPath 的繪圖結果。

建立 SwiftUI App 專案

建立專案時,Interface 選擇 SwiftUI。

ContentView.swift 貼上以下程式

將 ContentView.swift 原本的程式刪除,貼上以下程式。以下程式將利用 SwiftUI 幫我們預覽繪製的圖案。此次作業的重點在繪圖,所以不懂以下程式沒關係。

import SwiftUI

struct DrawView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView()

return view
}

func updateUIView(_ uiView: UIView, context: Context) {
}

}

struct ContentView: View {
var body: some View {
DrawView()
}
}

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

ps: 已有一定的 SwiftUI 基礎,對以上程式有興趣的朋友可參考以下連結的說明。

預覽 App 畫面

編輯 ContentView.swift 的程式時,我們可從右邊看到它對應的 App 畫面。

在 function makeUIView 裡繪圖

剛剛我們順利地看到了預覽畫面,但是它卻是一片空白。這是當然的,因為我們還沒有繪製任何圖案。

接下來我們將在 function makeUIView 裡利用 UIBezierPath 繪圖,將繪圖的程式寫在 let view = UIView() & return view 之間。

func makeUIView(context: Context) -> UIView {
let view = UIView()
return view
}

利用 UIBezierPath 繪製路徑

在繪圖之前請先參考以下文章的教學,了解利用 UIBezierPath 繪製路徑的原理。

以下我們在 makeUIView 裡利用 UIBezierPath 繪製三角形的路徑。

func makeUIView(context: Context) -> UIView {
let view = UIView()

let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 100, y: 100))
path.close()

return view
}

生成能顯示在畫面上的形狀,CAShapeLayer

UIBezierPath 繪製的路徑是虛擬的,無法顯示在 App 畫面上,就像我們告白時用手在空氣中畫愛心一樣。因此接下來我們要產生能顯示在畫面上的 CAShapeLayer。

CAShapeLayer 代表某種形狀,我們透過它的 path 設定形狀。然而 triangleLayer.path 的型別是 CGPath,因此以下程式將產生錯誤。

triangleLayer.path 的型別是 CGPath

我們必須利用 path.cgPath 讀取 CGPath 型別的三角形路徑後再存入 triangleLayer.path。

func makeUIView(context: Context) -> UIView {
let view = UIView()

let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 100, y: 100))
path.close()

let triangleLayer = CAShapeLayer()
triangleLayer.path = path.cgPath

return view
}

CAShapeLayer 加到 view 上

此時預覽畫面還是一片空白,因為它顯示的是 view,而不是我們剛剛產生的 triangleLayer因此我們還要加入關鍵的一行,利用 view.layer.addSublayer(triangleLayer) 將 triangleLayer 加到 view 上。

func makeUIView(context: Context) -> UIView {
let view = UIView()

let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 100, y: 100))
path.close()

let triangleLayer = CAShapeLayer()
triangleLayer.path = path.cgPath
view.layer.addSublayer(triangleLayer)

return view
}

預覽繪製的形狀

Cool,我們成功地在右邊的預覽畫面看到三角形。

而且更令人開心的,當我們對繪製的程式做任何修改時,馬上就能在右邊的預覽畫面看到結果,實現一邊以程式畫圖,一邊從預覽畫面觀看結果的夢想。

不過有時它會忘了自動更新,此時可按快速鍵 cmd + option + p 強迫它更新。

設定 CAShapeLayer 填滿形狀的顏色

CAShapeLayer 預設以黑色填滿,我們可以設定 CAShapeLayer 的 fillColor,改變它填滿的顏色。由於 fillColor 的型別是 CGColor,所以我們需要給它型別 CGColor 的東西。

triangleLayer.fillColor = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1)

繪製線條

有時我們希望繪製線條,而不是填滿的形狀。此時可透過 CAShapeLayer 的 strokeColor 設定邊框的顏色,lineWidth 設定邊框的寬度。(stroke 描述繪圖的筆畫,因此 strokeColor 表示 UIBezierPath 畫出的線條顏色。)

let triangleLayer = CAShapeLayer()
triangleLayer.path = path.cgPath
triangleLayer.strokeColor = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1)
triangleLayer.lineWidth = 10
view.layer.addSublayer(triangleLayer)

不過此時三角形的中間卻是黑色的,因為 CAShapeLayer 預設會填滿黑色。若想只有線條,我們可以將 fillColor 設為代表透明的 clear color。

let triangleLayer = CAShapeLayer()
triangleLayer.path = path.cgPath
triangleLayer.strokeColor = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1)
triangleLayer.fillColor = UIColor.clear.cgColor
triangleLayer.lineWidth = 10
view.layer.addSublayer(triangleLayer)

加入多個 layer

我們可利用 addSublayer 加入多個 layer,比方以下例子,加入一個藍色的實心三角形和紅色的空心三角形。

func makeUIView(context: Context) -> UIView {
let view = UIView()

// 藍色實心三角形
var path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 50))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 100, y: 100))
path.close()
let blueTriangleLayer = CAShapeLayer()
blueTriangleLayer.path = path.cgPath
blueTriangleLayer.fillColor = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1)
view.layer.addSublayer(blueTriangleLayer)

// 紅色空心三角形
path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 150))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 200, y: 200))
path.close()
let redTriangleLayer = CAShapeLayer()
redTriangleLayer.path = path.cgPath
redTriangleLayer.fillColor = UIColor.clear.cgColor
redTriangleLayer.lineWidth = 10
redTriangleLayer.strokeColor = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)
view.layer.addSublayer(redTriangleLayer)

return view
}

由於我們先加入 blueTriangleLayer,再加入 redTriangleLayer,因此 redTriangleLayer 會蓋住 blueTriangleLayer,愈後面加入的在愈上層,就像疊疊樂一樣。

繪製特定形狀的三個步驟

透過剛剛的例子,我們了解繪製特定形狀可透過以下三個步驟實現:

  • 利用 UIBezierPath 繪製形狀的路徑。
  • 產生 CAShapeLayer,將它變成 UIBezierPath 繪製的形狀。
  • 利用 addSublayer 加入 CAShapeLayer。

UIView & CAShapeLayer

UIView & CAShapeLayer 都能顯示在畫面上,因此設計 App 畫面時,我們可以利用一個個 UIView 堆疊畫面,也可以利用 CAShapeLayer。一個 view 上可以呼叫 addSubview 加入很多 view 當它的 subview,一個 view 上也可以呼叫 addSublayer 加入很多 layer 當它的 sublayer。

以下範例我們在 view 上依序加入 greenView,redTriangleLayer,blueTriangleLayer,yellowView,因此它們的階層順序將是綠色正方形在最底層,黃色長方形在最上層。

func makeUIView(context: Context) -> UIView {
let view = UIView()

// 綠色正方形
let greenView = UIView(frame: CGRect(x: 10, y: 10, width: 150, height: 150))
greenView.backgroundColor = UIColor.green
view.addSubview(greenView)

// 藍色實心三角形
var path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 50))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 100, y: 100))
path.close()
let blueTriangleLayer = CAShapeLayer()
blueTriangleLayer.path = path.cgPath
blueTriangleLayer.fillColor = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1)
view.layer.addSublayer(blueTriangleLayer)

// 紅色空心三角形
path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 150))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 200, y: 200))
path.close()
let redTriangleLayer = CAShapeLayer()
redTriangleLayer.path = path.cgPath
redTriangleLayer.fillColor = UIColor.clear.cgColor
redTriangleLayer.lineWidth = 10
redTriangleLayer.strokeColor = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)
view.layer.addSublayer(redTriangleLayer)

// 黃色長方形
let yellowView = UIView(frame: CGRect(x: 60, y: 70, width: 80, height: 50))
yellowView.backgroundColor = UIColor.yellow
view.addSubview(yellowView)

return view
}

運用 UIBezierPath 繪製可愛圖案

了解如何繪製簡單的三角形後,接下來讓我們進一步挑戰參考圖片繪製可愛的圖案。

準備繪製的圖片

以可愛的星星弟弟為例。

https://www.pinterest.com/pin/331014641344432049/

建議圖片先縮小到小於 iPhone 螢幕的尺寸,這樣繪製時才不會超出 iPhone 的螢幕。在 Mac 上修改圖片尺寸的方法可參考以下連結。

將圖片上傳到查詢座標顏色的網站 iview

參考圖片繪圖時最困難的莫過於如何得知圖片裡形狀的座標和顏色。不過別擔心,感謝方便的 iview 網站,我們只要將圖片上傳,即可查詢圖片的座標顏色。(ps: 如果覺得圖片太小不容易查座標,可按 cmd + 將網頁放大。)

https://yangcha.github.io/iview/iview.html

依據網站標示的座標顏色繪製星星

繪製時建議先繪製線條,方便我們從右邊的預覽觀看形狀。因此我們先設定 CAShapeLayer 的 strokeColor & lineWidth,並將 fillColor 設為透明,

形狀畫好後,接著要移除線條或填滿顏色都可以。比方我們想貪心地同時擁有線條跟填滿顏色,完整程式如下:

func makeUIView(context: Context) -> UIView {
let view = UIView()

let path = UIBezierPath()
path.move(to: CGPoint(x: 182, y: 27))
path.addLine(to: CGPoint(x: 224, y: 120))
path.addLine(to: CGPoint(x: 322, y: 139))
path.addLine(to: CGPoint(x: 258, y: 217))
path.addLine(to: CGPoint(x: 270, y: 326))
path.addLine(to: CGPoint(x: 182, y: 282))
path.addLine(to: CGPoint(x: 90, y: 326))
path.addLine(to: CGPoint(x: 101, y: 221))
path.addLine(to: CGPoint(x: 28, y: 143))
path.addLine(to: CGPoint(x: 128, y: 121))
path.close()
let starLayer = CAShapeLayer()
starLayer.path = path.cgPath
starLayer.fillColor = UIColor.yellow.cgColor
starLayer.strokeColor = UIColor.black.cgColor
starLayer.lineWidth = 5
view.layer.addSublayer(starLayer)

return view
}

現在我們已經成功畫出星星,但要成為下圖可愛的星星弟弟還有滿大的距離。仔細觀察下圖,星星弟弟有一些形狀和線條是曲線,可不是單純的直線呢。

繪製彎曲的曲線

  • 畫出某個弧度的曲線

使用 addQuadCurve。

func makeUIView(context: Context) -> UIView {
let view = UIView()

let path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 50))
path.addQuadCurve(to: CGPoint(x: 50, y: 200), controlPoint: CGPoint(x: 150, y: 125))

let layer = CAShapeLayer()
layer.path = path.cgPath
layer.lineWidth = 3
layer.strokeColor = UIColor.black.cgColor
layer.fillColor = UIColor.clear.cgColor
view.layer.addSublayer(layer)

return view
}

如下圖所示,controlPoint 控制曲線的弧度。剛剛 addQuadCurve(to: CGPoint(x: 50, y: 200), controlPoint: CGPoint(x: 150, y: 125)) 的參數 to 對應下圖的 C 點,controlPoint 對應 B 點,那麼 A 點呢 ? A 點是在呼叫 addQuadCurve 前畫到的位置,因此是從 (50, 50) 開始。

以下為剛剛的 controlPoint 改成 (10, 125) & (300, 125) 的例子。

使用 addCurve(to:controlPoint1:controlPoint2:)。

有兩個 control 點。

let path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 50))
path.addCurve(to: CGPoint(x: 50, y: 200), controlPoint1: CGPoint(x: 150, y: 125), controlPoint2: CGPoint(x: 10, y: 160))
  • 繪製剛好在圓上的圓弧

利用 UIBezierPath 的 init(arcCenter:radius:startAngle:endAngle:clockwise:) 繪製圓弧。

比方以下程式可畫出只能看不能吃的西瓜。

func makeUIView(context: Context) -> UIView {
let view = UIView()

let aDegree = CGFloat.pi / 180
let path = UIBezierPath(arcCenter: CGPoint(x: 50, y: 50), radius: 40, startAngle: aDegree * 0, endAngle: aDegree * 180, clockwise: true)

let layer = CAShapeLayer()
layer.path = path.cgPath
layer.fillColor = UIColor.red.cgColor
view.layer.addSublayer(layer)

return view
}

參數說明:

arcCenter: 圓心座標。

radius: 半徑。

startAngle & endAngle: 圓弧開始和結束的角度。圓形有 360 度,如下圖所示,在程式裡我們以 2 pi 表示 360 度,1 pi 表示 180 度。

我們都知道 pi 是 3.1415926… ,可是我們只記得前面幾位,不記得後面的數字。沒關係,iOS SDK 已經幫我們定義好,透過 CGFloat.pi 即可取得。

因此 1 度是 pi / 180,我們利用 let aDegree = CGFloat.pi / 180 算出一度的大小,存在常數 aDegree。而角度 0 度的位置在右邊,180 度在左邊。startAngle 傳入 aDegree * 0,endAngle 傳入 aDegree * 180時,表示從 0 度畫到 180 度。不過由於這個例子剛好整除,所以也可以簡化成傳入 0 & CGFloat.pi。

let path = UIBezierPath(arcCenter: CGPoint(x: 50, y: 50), radius: 40, startAngle: 0, endAngle: CGFloat.pi, clockwise: true)

clockwise: 是否為順時針。畫圓時可以順時針,也可以逆時針。剛剛的例子, 參數 clockwise 傳入 true,因此我們順時針從 0 度畫到 180 度,產生西瓜形狀的下半圓。

其它例子:

逆時針從 10 度畫到 150 度。

let aDegree = CGFloat.pi / 180
let path = UIBezierPath(arcCenter: CGPoint(x: 50, y: 50), radius: 40, startAngle: aDegree * 10, endAngle: aDegree * 150, clockwise: false)

值得注意的,剛剛方法繪製的形狀填滿顏色時將以圓弧和圓弧兩端點連線包含的區塊,若想畫出從圓心連到圓弧兩端點的扇形,則須先用 move 移動到圓心,然後再呼叫 addArc(withCenter:radius:startAngle:endAngle:clockwise:) 加入圓弧,例如以下例子:

let aDegree = CGFloat.pi / 180
let path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 50))
path.addArc(withCenter: CGPoint(x: 50, y: 50), radius: 40, startAngle: aDegree * 10, endAngle: aDegree * 150, clockwise: false)

繪製馬蹄形。

let aDegree = CGFloat.pi / 180
// 畫出比較大的外圓弧,從 (0, 50) 到 (100, 50)
let path = UIBezierPath(arcCenter: CGPoint(x: 50, y: 50), radius: 50, startAngle: aDegree * 180, endAngle: aDegree * 0, clockwise: true)
// 畫出右下方的橫線,從 (100, 50) 連到 (75, 50)
path.addLine(to: CGPoint(x: 75, y: 50))
// 畫出比較小的內圓弧,從 (75, 50) 到 (25, 50)
path.addArc(withCenter: CGPoint(x: 50, y: 50), radius: 25, startAngle: aDegree * 0, endAngle: aDegree * 180, clockwise: false)
path.close()

關於曲線繪製的更多說明,也可以參考 Jaba 同學的文章。

合併 path 繪製多個形狀

若是想繪製的多個形狀是同一個樣式,我們也可以用一個 UIBezierPath & CAShapeLayer 繪製,而不用分成多個。如以下程式所示,當我們第一次呼叫 path.close 時代表畫完第一個三角形的 path,接著呼叫 path.move 將移動畫筆,準備畫第二個形狀。

func makeUIView(context: Context) -> UIView {
let view = UIView()

let path = UIBezierPath()

// 繪製第一個三角形的 path
path.move(to: CGPoint(x: 0, y: 50))
path.addLine(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 100, y: 100))
path.close()

// 繪製第二個三角形的 path
path.move(to: CGPoint(x: 50, y: 150))
path.addLine(to: CGPoint(x: 120, y: 20))
path.addLine(to: CGPoint(x: 200, y: 200))
path.close()

let triangleLayer = CAShapeLayer()
triangleLayer.path = path.cgPath
triangleLayer.fillColor = UIColor.clear.cgColor
triangleLayer.lineWidth = 3
triangleLayer.strokeColor = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)
view.layer.addSublayer(triangleLayer)

return view
}

利用 CGAffineTransform 控制元件的縮放,位移和旋轉

需要繪製對稱圖形,比方星星,或是繪製多個一樣形狀的圖案,可研究利用 CGAffineTransform 控制元件的縮放,位移和旋轉。

將 view 變成任意形狀的三種方法

剛剛我們利用 addSublayer 加入不同的形狀,在 iOS 上將 view 變成任意形狀的方法不只一種,比方利用 mask & 定義 function draw 也可以,有興趣的朋友可參考以下連結。

將 SVG 變成 UIBezierPath

同學作品集

剛剛的星星弟弟只是小 case,同學們都用了無與輪比的耐心以 Swift 畫出一個個精彩的作品,有興趣的朋友可參考以下連結慢慢欣賞。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com