在開發 App 時,有一件事情是開發者一定要做的,就是繪製一些簡單圖形。我們可以利用 Paint 或 Preview 繪製這些簡單的圖形,這兩個都是很好的 App,但有時還是會有點不足。因為當我們要建立一個圖形的點陣圖 (Bitmap) 時,總會有想要的 iOS 顏色或尺寸。雖然我們都可以在 Paint/Preview 中設定這兩個屬性,但有時卻無法符合完美像素 (pixel-perfect)。
在這篇文章中,我會帶大家構建一個簡單的繪畫 App,來解決這個問題。在文章的結尾,大家也可以下載完整的 App。
簡介
我想構建一個 iOS App 來繪製一些簡單的圖形。這個 App 的操作應該像 Paint 或 Preview App 那樣直觀,我們可以以不同顏色創建、刪除、調整尺寸、複製和貼上、以及任意擺放不同圖形,也可以構建特定尺寸的圖形,圖形可以是線條,也可以是不同的形狀,至少要有三角形、正方形和圓形。當然,我們也需要儲存圖形,並簡單匯出到開發平台的功能。與 Preview 和 Paint App 不同的是,我想 App 可以有圖層的概念。如果 App 可以讓我們添加文本到圖形、以及繪製線條就更好了。
編寫程式碼
我最初是想構建一個混合 UIKit/SwiftUI 的 App,因為 SwiftUI 中沒有觸摸手勢 (touch gesture)。幸好,我發現 SwiftUI 中可以使用最小距離為 0 的拖動手勢,這樣其操作就會與 UIKit 上的觸摸手勢相同。因此,最後我利用了純 SwiftUI 構建這個 App。
第一個版本最複雜的地方,就是創建 (creating) 與選擇 (selecting) 圖形背後的邏輯,這個步驟需要反複試驗才能解決。我從 ObservableObject
開始,並利用它把 Canvas 視圖和 ContentView
的數據共享。
class Cords: ObservableObject {
@Published var cord:[CGPoint] = [CGPoint](repeating: CGPoint.zero, count: 128)
@Published var size:[CGSize] = [CGSize](repeating: CGSize.zero, count: 128)
@Published var selected:[Bool] = [Bool](repeating: false, count: 128)
static var shared = Cords()
}
我發現利用 1 個結構會比用 3 個陣列 (array) 更好,不過我在構建原型 (prototype) 的時候添加了所謂元素,我很快就會重構這個部分。
接著,我們要設置 CanvasView
,這個視圖會在 Canvas 的圖層中構建不同圖形。文末的範例就用了橢圓形,它比 rect
好用,我會再花時間改善這個功能。
struct LeCanvas: View {
@ObservedObject var cords = Cords.shared
@Binding var indx:Int
var body: some View {
Canvas(opaque: false, colorMode: .nonLinear, rendersAsynchronously: true, renderer: { context, size in
for i in 0..<cords.cord.count {
context.drawLayer { layerContext in
layerContext.withCGContext { cgContext in
if cords.cord[i] != CGPoint.zero {
cgContext.move(to: cords.cord[i])
let rect = CGRect(origin: cords.cord[i], size: cords.size[i])
let path = CGPath(rect: rect, transform: nil)
// let path = CGPath(ellipseIn: rect, transform: nil)
cgContext.addPath(path)
cgContext.setStrokeColor(cords.selected[i] ? UIColor.red.cgColor: UIColor.blue.cgColor)
cgContext.setFillColor(UIColor.clear.cgColor)
cgContext.setLineWidth(2)
cgContext.drawPath(using: .eoFillStroke)
}
}
}
}
})
}
}
在這裡,我們希望使用這篇文章所介紹的方法,以 X edges 的路徑 (path) 來建構一個圖形。
接下來,在 ContentView
中,我們就可以用這個邏輯,來設置選擇/取消 選擇圖像的操作。
struct ContentView: View {
@GestureState var foo = CGPoint.zero
@ObservedObject var cords = Cords.shared
@State var indx = 0
@State var selected = false
@State var newShape = true
@State var selectedIndx = 0
var body: some View {
LeCanvas(indx: $indx)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
.updating($foo) { value, state, transaction in
if newShape {
let nudge = CGSize(width: CGFloat(cords.size[indx].width), height: CGFloat(cords.size[indx].height))
let newPoint = CGPoint(x: value.location.x - (nudge.width/2), y: value.location.y - (nudge.height/2))
cords.cord[indx] = newPoint
if value.translation.width > 4 || value.translation.height > 4 {
cords.size[indx] = CGSize(width: value.translation.width, height: value.translation.height)
}
}
if selected {
let shape = CGRect(origin: cords.cord[selectedIndx], size: cords.size[selectedIndx])
if shape.contains(value.location) {
let nudge = CGSize(width: CGFloat(cords.size[selectedIndx].width), height: CGFloat(cords.size[selectedIndx].height))
let newPoint = CGPoint(x: value.location.x - (nudge.width/2), y: value.location.y - (nudge.height/2))
cords.cord[selectedIndx] = newPoint
}
}
}.onEnded({ value in
newShape = false
selected = true
if value.translation.width < 4 && value.translation.height < 4 {
if searchin(value: value, cords: cords) {
DispatchQueue.main.async {
selected = true
newShape = false
}
} else {
DispatchQueue.main.async {
deselect(cords: cords)
selected = false
newShape = true
indx += 1
}
}
}
})
)
HStack {
Text("Selected \(selected.description) \(indx)")
Text("New Shape \(newShape.description)")
.onTapGesture {
DispatchQueue.main.async {
indx += 1
}
}
}
}
在以上的程式碼中,我在除錯 (debug) 的時候用了兩個變數,來為我們追蹤一些重要的變數。另外,你也可以注意到我在幾個地方用了 DispatchQueue
,這讓我們可以繞過紫色警告,並確保 UI 僅在主執行緒 (main thread) 上更新。
最後,我們需要構建 2 個 helper routine,來選擇/取消選擇螢幕上的物件:
func deselect(cords:Cords) {
for i in 0..<cords.cord.count {
if cords.cord[i] != CGPoint.zero {
DispatchQueue.main.async {
cords.selected[i] = false
}
}
}
}
func searchin(value: GestureStateGesture<DragGesture, CGPoint>.Value, cords:Cords) -> Bool {
for searchin in 0..<cords.cord.count {
let shape = CGRect(origin: cords.cord[searchin], size: cords.size[searchin])
if shape.contains(value.location) {
selectedIndx = searchin
cords.selected[searchin] = true
return true
}
}
return false
}
現在,讓我們整合所有程式碼,並在模擬器上運行,就可以試用範例 App 了!
從以上的 GIF 可見,我畫了一個圓形,只要點擊圓形邊框內的位置,就可以選擇圖形,並將其移動到其他圓形旁邊。然後我點擊其他位置,就可以取消選擇它,並創建另一個圖形。
我還沒有試過用這個 App 繪製客製化圖形,讓我們在下一篇文章再深入探討。謝謝你的閱讀。