PencilKitでページャーつき絵描きアプリを作ってみた

こんにちは、アイスタイルでiOS @cosmeの開発をしている小笠原です。
普段はチームリーダーとして働いています。
趣味ではFF14のiOS版タイマーアプリをほそぼそと開発しています。

この記事は アイスタイル Advent Calendar 2019 8日目の記事です。

PencilKitについて

今回はiOS13から使えるPencilKitを使い、ページャーつきのお絵かきアプリを作ってみましたので、こんなときはどうしたらいいの?というときのヒントになれば幸いです。

サンプルコード

こちらにサンプルアプリを用意しました。

このアプリでできること

以下の項目はページ単位で指定が可能です
1. 単一の画面内で絵を書きながらページ移動
2. ページ追加
3. キャンバスに背景色/背景画像を指定
4. キャンバスの描画情報と背景色/背景画像を合成して、1枚の画像に変換
5. キャンバスのデータをDataに変換
6. 他で生成されたキャンバス情報をロードして画面に表示(Undo/Redo状態も維持)
7. キャンバス上のスクロールに対応
8. ToolPickerの出し入れ
9. ページごとに編集・閲覧モードにできる

単一の画面内で絵を書きながらページ移動

画面に前と次へというボタンを追加して、それぞれでキャンバスを切り替える場合にはPKDrawingを配列で持たせ、ボタンが押されるたびにPKCanvasView.drawingへ設定することで、ページごとのキャンバスの状態を復元できます。

var pageDraws: [Draw] = []

private func prevDrawing() {
    saveCurrentPage()

    if currentPageIndex > 0 {
        currentPageIndex -= 1
        if let prevDrawingData = pageDraws[currentPageIndex].drawingData, let prevDrawing = try? PKDrawing(data: prevDrawingData) {
            canvasView.drawing = prevDrawing
        }
    }
    updateUI()
}

private func nextDrawing() {
    saveCurrentPage()

    if currentPageIndex < maxPageNum - 1 {
        currentPageIndex += 1
        if let nextDrawingData = pageDraws[currentPageIndex].drawingData, let nextDrawing = try? PKDrawing(data: nextDrawingData) {
            canvasView.drawing = nextDrawing
        }
    }
    updateUI()
}

ページ追加

ページ追加はページ切り替えと同じで、PKDrawingを配列に追加した後、PKCanvasView.drawingに設定することで実現できます。

addPageButton.rx.tap.asDriver().drive(onNext: { [weak self] in
    let draw = Draw()
    draw.thumbnailImage = UIImage(named: "thumbnail")
    // draw.backgroundColor = .red
   self?.appendPage(draw)
}).disposed(by: disposeBag)

private func appendPage(_ draw: Draw) {
    saveCurrentPage()
    pageDraws.append(draw)
    changePage(at: pageDraws.endIndex - 1)
}

private func changePage(at index: Int) {
    templateImageView?.image = pageDraws[index].thumbnailImage

    if let drawingData = pageDraws[index].drawingData, let drawing = try? PKDrawing(data: drawingData) {
        canvasView.drawing = drawing
    } else {
        canvasView.drawing = PKDrawing()
    }
    currentPageIndex = index
}

キャンバスに背景色/背景画像を指定

背景色を設定する場合はPKCanvasView.Opaquefalseにした上で、backgroundColorを指定すると反映されます。
背景画像の指定は標準で用意されていないので、UIImageViewPKCanvasViewaddSubViewすることで背景画像を表示した状態で描くことができます。

var canvasViewBackgroundColor: UIColor = .clear {
    didSet {
        if pageDraws[currentPageIndex].thumbnailImage != nil {
            canvasView.backgroundColor = UIColor.clear
        } else {
            canvasView.backgroundColor = self.canvasViewBackgroundColor
        }
    }
}

private func initUI() {
    if let firstDrawing = pageDraws.first {
        canvasViewBackgroundColor = firstDrawing.backgroundColor
        templateImageView?.image = firstDrawing.thumbnailImage
    }
    if let firstDrawingData = pageDraws.first?.drawingData, let firstDrawing = try? PKDrawing(data: firstDrawingData) {
        canvasView.drawing = firstDrawing
    }

    templateImageView = UIImageView()
    templateImageView?.clipsToBounds = true
    templateImageView?.contentMode = .scaleAspectFill
    if let defaultThumbnailImageView = templateImageView {
        canvasView.addSubview(defaultThumbnailImageView)
        canvasView.sendSubviewToBack(defaultThumbnailImageView)
    }
    canvasView.allowsFingerDrawing = true
}

キャンバスの描画情報と背景色/背景画像を合成して、1枚の画像に変換

背景色の場合はPKCanvasViewそのものを画像に変換するだけです。
背景画像と描画情報を合成して1枚の画像に変換する場合には、CGContext上に背景画像と描画情報を合成して1枚にしています。

private func saveCurrentPage() {
    pageDraws[currentPageIndex].drawingData = canvasView.drawing.dataRepresentation()

    delay(0.3) { [weak self] in
        guard let `self` = self else { return }

        if let templateImage = self.pageDraws[self.currentPageIndex].thumbnailImage {
            // PKCanvasView上のレンダリング情報とテンプレート画像を合成して一枚絵にする。
            // キャンバスのスクロールが有効の場合にはScrollView全域を対象とするためにContenSizeを使用する
            let contentSize = self.canvasView.contentSize
            let contentRect = CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height)
            let drawImage = templateImage.composite(image: self.canvasView.drawing.image(from: contentRect, scale: 1.0))

            self.pageDraws[self.currentPageIndex].imageData = drawImage.jpegData(compressionQuality: 0.7)
            UIImageWriteToSavedPhotosAlbum(drawImage, self, #selector(self.didFinishSavingImage(_:didFinishSavingWithError:contextInfo:)), nil)
        } else {
            let drawImage = self.canvasView.getImage()
            self.pageDraws[self.currentPageIndex].imageData = drawImage.jpegData(compressionQuality: 0.7)
            UIImageWriteToSavedPhotosAlbum(drawImage, self, #selector(self.didFinishSavingImage(_:didFinishSavingWithError:contextInfo:)), nil)
        }
    }
}

extension UIImage {

    func composite(image: UIImage) -> UIImage {

        UIGraphicsBeginImageContextWithOptions(self.size, false, 0)

        draw(in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
        image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))

        let image = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()

        return image
    }
}

extension UIView {

    func getImage() -> UIImage {

        let rect = self.bounds

        UIGraphicsBeginImageContextWithOptions(rect.size, false, 2.0)
        let context: CGContext = UIGraphicsGetCurrentContext()!

        layer.render(in: context)

        let image: UIImage = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()

        return image
    }
}

キャンバスのデータをDataに変換

描画情報を他デバイス、サーバーに保存、ローカルストレージに保存できる形式に変換する場合にはPKDrawing.dataRepresentation()を使います。

pageDraws[currentPageIndex].drawingData = canvasView.drawing.dataRepresentation()

他で生成されたキャンバス情報をロードして画面に表示(Undo/Redo状態も維持)

PKDrawingData化したものはPKCanvasView.drawingに設定できます。
そのため、デバイス間をまたがって描画情報を復元したり、APIで描画情報をDBに保存してから、好きなタイミングで取得して画面に状態ごと復元できます。
保存後に描いた線を消したりすることが簡単にできるようになるというのもメリットの一つです。

キャンバス上のスクロールに対応

PKCanvasViewUIScrollViewを継承しているので、縦スクロールも可能になっています。
縦を大きく使う場合にはcontentSizeを調整することでスクロールしながら描くということができます。

var isEnabledScoll: Bool = false {
    didSet {
        canvasView.isScrollEnabled = self.isEnabledScoll
    }
}

ToolPickerの出し入れ

ToolPickerの出し入れは以下のように実装しています

private func enableEditCanvas() {
    canvasView.drawingGestureRecognizer.isEnabled = true // ペンでかけるようにする

    if let window = UIApplication.shared.windows.first, let toolPicker = PKToolPicker.shared(for: window) {
        toolPicker.setVisible(true, forFirstResponder: canvasView)
        toolPicker.addObserver(canvasView)
        toolPicker.addObserver(self)
        canvasView.becomeFirstResponder()
    }
}

private func disableEditCanvas() {
    canvasView.drawingGestureRecognizer.isEnabled = false // ペンでかけないようにする

    if let window = UIApplication.shared.windows.first, let toolPicker = PKToolPicker.shared(for: window) {
        toolPicker.setVisible(false, forFirstResponder: canvasView)
        toolPicker.removeObserver(canvasView)
        toolPicker.removeObserver(self)
    }
}

ページごとに編集・閲覧モードにできる

PKCanvasViewの大本はUIViewなので、Gesture.isEnabledを切り替えることでできます。これとセットでToolPickerの出し入れも制御すると閲覧・編集モードみたいなUIにできます。

canvasView.drawingGestureRecognizer.isEnabled = true // ペンでかけるようにする

参考にした記事

WWDC 2019 PencilKit
PencilKit

おわりに

これまで絵描き機能を作る際には一手間かかっていたものが、PencilKitを使うことにより、少ないコードでApple標準のメモ機能と同じことがアプリ内で簡単に実装できるようになったのは嬉しいことですね。
さらにサーバーにデータを保存して、アプリでロードした際に描いた情報を消すことができるというのも非常に便利でした。

最後まで読んでいただきありがとうございます。

iOS @cosmeの開発とマネージャー的な役を兼務しています。