運用 UIBezierPath、CAShapeLayer 繪製小小兵

利用UIBezierPath繪製路徑、CAShapeLayer產生形狀

--

黃色是原圖・ 紫色是程式繪製

製作檔案

本次繪製用到以下功能:

  1. 利用UIBezierPath繪製路徑
  2. 利用CAShapeLayer產生形狀
  3. 利用 CAGradientLayer & mask 讓 CAShapeLayer 繪製的形狀變漸層
  4. 使用 CABasicAnimation 設定動畫

這次製作專案比較特殊且不同於以往:這次建立的是Swift UI專案。好處就是,只要程式變動都可以即時用Swift UI 的 live preview預覽效果。例如座標標錯下錯筆、顏色挑錯變災難等等,都可以馬上看到、馬上修正,不用特別按Play啟動模擬器看畫面。

繪製特定形狀的三個步驟

  1. 利用 UIBezierPath 繪製形狀的路徑 →(此時路徑畫完還不會顯示在模擬器上)
    move(to:) → 移動到某座標
    addLine(to:) → 加直線
    addQuadCurve(to:controlPoint:) → 用一個控制點把直線拉成弧形
    addCurve(to:controlPoint1:controlPoint2:) → 用兩個控制點把直線拉成 兩個弧形
    close() → 自動連“直線”到起點座標
  2. 產生 CAShapeLayer,將UIBezierPath 路徑變成形狀。→(路徑及屬性顯示在模擬器上)
    strokeColor → 線條顏色
    lineWidth → 線條寬度
    fillColor → 形狀填色,型別是cgColor
  3. 利用 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)

畫左半身,第一條曲線的終點(x: 60, y: 200)是下一條直線的起點,直線的終點是(x: 58, y: 400)

回到畫頭髮的部分,close()是自動連“直線”回起點座標,所以如果畫到髮尾用close()回到髮根可以嗎?
也不是不行~只是頭髮會變的像草根一樣粗硬!原圖以純曲線畫頭髮,但如果用了close()會畫出完美直線回起點座標,反而畫出一個完美弓形

左邊第一根頭髮變弓型

而畫長方形,因爲是直線,所以可以用close()回起點

CAShapeLayer將路徑變成形狀

繪製的時候有個疑問:UIBezierPath路徑要畫到什麼程度才能呼叫一次CAShapeLayer變成形狀呢?如果將小小兵吃切成超多部位繪製,是不是每一個部位就要呼叫一次?

答案是yes也是no。yes的原因是,如果希望調整各部位的屬性—線條顏色strokeColor、線條寬度lineWidth、內部填色fillColor時,就需要各部位畫完後呼叫一次CAShapeLayer。
no的原因是,例如小小兵外框、瞳孔、鞋子等都相同顏色、線寬時,UIBezierPath畫完後統一呼叫一次CAShapeLayer也OK。此種情況下,
部位之間筆畫沒有連續時,前一部位畫完,用move(to:)到下一部位繼續繪製即可。如四根頭毛日後沒有打算讓他分別挑染,所以共同呼叫一個CAShapeLayer()

製作小小兵右半邊部位,用CGAffineTransform做位移和翻轉

製作小小兵右半邊時,同樣要幫右半部位呼叫CAShapeLayer顯示形狀,並且讓右半路徑=左半路徑,再設定與左半部位相同的屬性。
以身體舉例,小小兵的左半身路徑=右半身路徑,並且左右相反。所以要先移動,再用scaledBy(x: -1, y: 1)做鏡像翻轉。

let moveDistance = leftBodyPath.bounds.maxX*2指的是:
讓移動距離=構成右半身的所有座標(包含控制點)中,最大的X座標值*2

先移動(左圖),再水平翻轉(右圖)

關於畫圓

小小兵的圓型鏡框+眼睛+瞳孔是使用UIBezierPath畫圓

褲子Logo則生成一個圓型view,在裡面用UIBezierPath畫菱形,使菱形跟褲子一樣顏色

利用 CAGradientLayer & mask 讓 CAShapeLayer 繪製的形狀變漸層 &使用 CABasicAnimation 設定動畫

因為篇幅太長,請見下一篇文章

其他

這次繪圖時發現命名真的重要性,因為一下是畫path、layer,還要包裝成function,所以特別注意了命名規則。

畫路徑的時候因為細分太多部位了,所以UIBezierPath相關命名[部位path(型別名稱)];
CAShapeLayer則命名[部位layer(型別名稱)

func代表的是命令、方法(method)或是行為(behavior),程式尾呼叫func的目的是“生成”小小兵部位,所以func的命名是[動詞部位],包成func的好處是可以自由選擇要生成的部位

附上小小兵程式碼

--

--