利用 SwiftUI 在 iOS 15 中構建一個簡單的繪畫 App


本篇原文(標題:Build a Painting App in iOS 15 With SwiftUI)刊登於作者 Medium,由 Mark Lucking 所著,並授權翻譯及轉載。

在開發 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 了!

painting-app-swiftui

從以上的 GIF 可見,我畫了一個圓形,只要點擊圓形邊框內的位置,就可以選擇圖形,並將其移動到其他圓形旁邊。然後我點擊其他位置,就可以取消選擇它,並創建另一個圖形。

我還沒有試過用這個 App 繪製客製化圖形,讓我們在下一篇文章再深入探討。謝謝你的閱讀。

本篇原文(標題:Build a Painting App in iOS 15 With SwiftUI)刊登於作者 Medium,由 Mark Lucking 所著,並授權翻譯及轉載。

作者簡介:Mark Lucking,編程資歷超過 35 年,熱愛使用及學習 Swift/iOS 開發,定期在 Better ProgrammingThe StartUpMac O’ClockLevel Up Coding、及其它平台上發表文章。

譯者簡介:Kelly Chan-AppCoda 編輯小姐。


此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This