運用 UIBezierPath、CAShapeLayer 繪製小小兵
利用UIBezierPath繪製路徑、CAShapeLayer產生形狀
製作檔案
本次繪製用到以下功能:
- 利用UIBezierPath繪製路徑
- 利用CAShapeLayer產生形狀
- 利用 CAGradientLayer & mask 讓 CAShapeLayer 繪製的形狀變漸層
- 使用 CABasicAnimation 設定動畫
這次製作專案比較特殊且不同於以往:這次建立的是Swift UI專案。好處就是,只要程式變動都可以即時用Swift UI 的 live preview預覽效果。例如座標標錯下錯筆、顏色挑錯變災難等等,都可以馬上看到、馬上修正,不用特別按Play啟動模擬器看畫面。
繪製特定形狀的三個步驟
- 利用 UIBezierPath 繪製形狀的路徑 →(此時路徑畫完還不會顯示在模擬器上)
move(to:) → 移動到某座標
addLine(to:) → 加直線
addQuadCurve(to:controlPoint:) → 用一個控制點把直線拉成弧形
addCurve(to:controlPoint1:controlPoint2:) → 用兩個控制點把直線拉成 兩個弧形
close() → 自動連“直線”到起點座標 - 產生 CAShapeLayer,將UIBezierPath 路徑變成形狀。→(路徑及屬性顯示在模擬器上)
strokeColor → 線條顏色
lineWidth → 線條寬度
fillColor → 形狀填色,型別是cgColor - 利用 addSublayer 把 CAShapeLayer貼在view上。
把CAShapeLayer貼在view上,讓CAShapeLayer變成view的subLayer
最後使用 SwiftUI 預覽 UIBezierPath 的繪圖結果
SwiftUI的live view會自動跑修改結果,也可以按option+command+p preview
繪製過程介紹
小小兵的繪製規劃
- 將網路找的原圖用一個ImageView顯示疊在view上,將UIBezierPath直接畫在原圖上
- 查詢圖片座標。據說使用Illustrator有錨點可以清楚的確認座標,比較好畫曲線 https://yangcha.github.io/iview/iview.html
- 分解小小兵的部位:
1. 頭髮四根分開畫
2. 身體、手臂、手、鞋子、褲子、鏡片、瞳孔只畫左半邊,
右半邊以CGAffineTransform做位移和翻轉。如果鞋子會蓋住身體的情況,還是要盡量把身體畫完整。
3. 畫褲子Logo - 寫小小兵的名字Kevin,做出漸層效果
UIBezierPath 畫路徑
一開始繪製的時遇到了一個盲點,認為都要move(to:)該線的起點後才能addLine(to:) 線的終點。如畫四根頭髮時,都要從髮尾回到同一個起點才能畫下一根。
但上述作法到繪製身體時就行不通了,例如從頭頂到身體是一個曲線+直線+曲線的連續線條,因為addLine()或addQuadCurve(to:controlPoint:)會將上一條線的終點座標作為起點下筆,所以不用每次都使用move(to:)。
我們可以把move(to:)想像成是提筆到要下筆的位置,而畫連續線條時過程不需要提筆對吧?
畫頭毛,每條頭髮的起點都是(x: 140, y: 125),所以每根頭毛都要從髮尾用move(to:)移動回來髮根(x: 140, y: 125)
let hairPath = UIBezierPath()//畫第一根頭髮
hairPath.move(to: CGPoint(x: 140, y: 125))
hairPath.addQuadCurve(to: CGPoint(x: 112, y: 76), controlPoint: CGPoint(x: 133, y: 93))//畫第二根頭髮
hairPath.move(to: CGPoint(x: 140, y: 125))
hairPath.addQuadCurve(to: CGPoint(x: 122, y: 62), controlPoint: CGPoint(x: 140, y: 93))//畫第三根頭髮
hairPath.move(to: CGPoint(x: 140, y: 125))
hairPath.addQuadCurve(to: CGPoint(x: 162, y: 62), controlPoint: CGPoint(x: 140, y: 93))//畫第四根頭髮
hairPath.move(to: CGPoint(x: 140, y: 125))
hairPath.addQuadCurve(to: CGPoint(x: 170, y: 76), controlPoint: CGPoint(x: 148, y: 93))
畫左半身,第一條曲線的終點(x: 60, y: 200)是下一條直線的起點,直線的終點是(x: 58, y: 400)
//左半身let leftBodyPath = UIBezierPath()leftBodyPath.move(to: CGPoint(x: 140, y: 125))leftBodyPath.addQuadCurve(to: CGPoint(x: 60, y: 200), controlPoint: CGPoint(x: 65, y: 133))leftBodyPath.addLine(to: CGPoint(x: 58, y: 400))leftBodyPath.addQuadCurve(to: CGPoint(x: 100, y: 463), controlPoint: CGPoint(x: 54, y: 465))leftBodyPath.addLine(to: CGPoint(x: 100, y: 480))leftBodyPath.addQuadCurve(to: CGPoint(x: 83, y: 500), controlPoint: CGPoint(x: 88, y: 490))leftBodyPath.addLine(to: CGPoint(x: 134, y: 500))leftBodyPath.addLine(to: CGPoint(x: 134, y: 465))leftBodyPath.addLine(to: CGPoint(x: 140, y: 465))
回到畫頭髮的部分,close()是自動連“直線”回起點座標,所以如果畫到髮尾用close()回到髮根可以嗎?
也不是不行~只是頭髮會變的像草根一樣粗硬!原圖以純曲線畫頭髮,但如果用了close()會畫出完美直線回起點座標,反而畫出一個完美弓形
//畫第一根頭髮
hairPath.move(to: CGPoint(x: 140, y: 125))
hairPath.addQuadCurve(to: CGPoint(x: 112, y: 76), controlPoint: CGPoint(x: 133, y: 93))
hairPath.close()//CAShapeLayer顯示頭髮
而畫長方形,因爲是直線,所以可以用close()回起點
//畫眼鏡架: 畫一個長條的長方形let glassesFramePath = UIBezierPath()
glassesFramePath.move(to: CGPoint(x: 54, y: 190))
glassesFramePath.addLine(to: CGPoint(x: 230, y: 190))
glassesFramePath.addLine(to: CGPoint(x: 230, y: 210))
glassesFramePath.addLine(to: CGPoint(x: 54, y: 210))
glassesFramePath.close()//CAShapeLayer顯示眼鏡架: 一個長條的長方形
CAShapeLayer將路徑變成形狀
繪製的時候有個疑問:UIBezierPath路徑要畫到什麼程度才能呼叫一次CAShapeLayer變成形狀呢?如果將小小兵吃切成超多部位繪製,是不是每一個部位就要呼叫一次?
答案是yes也是no。yes的原因是,如果希望調整各部位的屬性—線條顏色strokeColor、線條寬度lineWidth、內部填色fillColor時,就需要各部位畫完後呼叫一次CAShapeLayer。
no的原因是,例如小小兵外框、瞳孔、鞋子等都相同顏色、線寬時,UIBezierPath畫完後統一呼叫一次CAShapeLayer也OK。此種情況下,
部位之間筆畫沒有連續時,前一部位畫完,用move(to:)到下一部位繼續繪製即可。如四根頭毛日後沒有打算讓他分別挑染,所以共同呼叫一個CAShapeLayer()
//用UIBezierPath畫第一、二、三、四根頭髮
//呼叫一次CAShapeLayer顯示全四根頭髮let minionLineColor = UIColor(red: 62/255, green: 57/255, blue: 53/255, alpha: 1)let hairLayer = CAShapeLayer()
hairLayer.path = hairPath.cgPath
hairLayer.strokeColor = minionLineColor.cgColor
hairLayer.lineWidth = 8
view.layer.addSublayer(hairLayer)
製作小小兵右半邊部位,用CGAffineTransform做位移和翻轉
製作小小兵右半邊時,同樣要幫右半部位呼叫CAShapeLayer顯示形狀,並且讓右半路徑=左半路徑,再設定與左半部位相同的屬性。
以身體舉例,小小兵的左半身路徑=右半身路徑,並且左右相反。所以要先移動,再用scaledBy(x: -1, y: 1)做鏡像翻轉。
let moveDistance = leftBodyPath.bounds.maxX*2
指的是:
讓移動距離=構成右半身的所有座標(包含控制點)中,最大的X座標值*2
frame:該view在superView座標系統中的位置和大小。(參照點是superView座標系統)
bounds:該view在本身座標系統中的位置和大小。(參照點是本身座標系統)
//畫完左半身體leftBodyPath//呼叫一次CAShapeLayer顯示左半身let leftBodyLayer = CAShapeLayer()
leftBodyLayer.path = leftBodyPath.cgPath
leftBodyLayer.strokeColor = minionLineColor.cgColor
leftBodyLayer.fillColor = bodyColor.cgColor
leftBodyLayer.lineWidth = 8
view.layer.addSublayer(leftBodyLayer)
//右半身:水平移動+翻轉左半邊身體let rightBodyLayer = CAShapeLayer()rightBodyLayer.path = leftBodyPath.cgPathlet moveDistance = leftBodyPath.bounds.maxX*2let transform = CGAffineTransform(translationX: moveDistance, y: 0).scaledBy(x: -1, y: 1)rightBodyLayer.setAffineTransform(transform)rightBodyLayer.strokeColor = minionLineColor.cgColorrightBodyLayer.fillColor = bodyColor.cgColorrightBodyLayer.lineWidth = 8view.layer.addSublayer(rightBodyLayer)
關於畫圓
小小兵的圓型鏡框+眼睛+瞳孔是使用UIBezierPath畫圓
// 左圓鏡框
let leftGlassesPath = UIBezierPath(arcCenter: CGPoint(x: 105, y: 200), radius: 35, startAngle: .pi/180 * 0, endAngle: .pi/180 * 360, clockwise: true)let leftGlassesLayer = CAShapeLayer()
leftGlassesLayer.path = leftGlassesPath.cgPath
leftGlassesLayer.strokeColor = minionLineColor.cgColor
leftGlassesLayer.fillColor = minionLineColor.cgColor
leftGlassesLayer.lineWidth = 8
view.layer.addSublayer(leftGlassesLayer)//左眼
let leftEyePath = UIBezierPath(arcCenter: CGPoint(x: 105, y: 200), radius: 23, startAngle: .pi/180 * 0, endAngle: .pi/180 * 360, clockwise: true)let leftEyeLayer = CAShapeLayer()
leftEyeLayer.path = leftEyePath.cgPath
leftEyeLayer.strokeColor = UIColor.white.cgColor
leftEyeLayer.fillColor = UIColor(white: 1, alpha: 1).cgColor
leftEyeLayer.lineWidth = 8
view.layer.addSublayer(leftEyeLayer)//左曈孔
let leftPupilPath = UIBezierPath(arcCenter: CGPoint(x: 110, y: 200), radius: 8, startAngle: .pi/180 * 0, endAngle: .pi/180 * 360, clockwise: false)let leftPupilLayer = CAShapeLayer()
leftPupilLayer.path = leftPupilPath.cgPath
leftPupilLayer.strokeColor = minionLineColor.cgColor
leftPupilLayer.fillColor = minionLineColor.cgColor
leftPupilLayer.lineWidth = 8
view.layer.addSublayer(leftPupilLayer)
褲子Logo則生成一個圓型view,在裡面用UIBezierPath畫菱形,使菱形跟褲子一樣顏色
let circleView = UIView(frame: CGRect(x: 113, y: 373.5, width: 56, height: 56))circleView.layer.cornerRadius = circleView.frame.size.width / 2circleView.backgroundColor = minionLineColorview.addSubview(circleView)
利用 CAGradientLayer & mask 讓 CAShapeLayer 繪製的形狀變漸層 &使用 CABasicAnimation 設定動畫
因為篇幅太長,請見下一篇文章
其他
這次繪圖時發現命名真的重要性,因為一下是畫path、layer,還要包裝成function,所以特別注意了命名規則。
畫路徑的時候因為細分太多部位了,所以UIBezierPath相關命名[部位
+path(型別名稱)
];
CAShapeLayer則命名[部位
+layer(型別名稱)
]
func代表的是命令、方法(method)或是行為(behavior),程式尾呼叫func的目的是“生成”小小兵部位,所以func的命名是[動詞
+部位
],包成func的好處是可以自由選擇要生成的部位
附上小小兵程式碼
UIBezierPath繪圖參考
CAShapeLayer相關參考
CGAffineTransform的基本使用介紹
CGAffineTransform的進階介紹 (小小兵右半身翻轉參考)