SwiftUI, Swift, iOS

[SwiftUI] GeometryReaderで特定のViewをキャプチャする

前回SwiftUIでお絵かきアプリの記事で指をドラッグすることでPathを作りお絵かきアプリを作りました。

作った絵は保存したくなりますよね?
というわけで今回はSwiftUIで特定のViewをキャプチャして画像にする方法を解説します。

サンプルコード

今回解説するサンプルコードはこちらです。

https://github.com/SatoTakeshiX/SwiftUICatalog/blob/master/GeometryReaderSample/GeometryReaderSample/DrawEditor.swift

アプリを試してみたい方は

https://github.com/SatoTakeshiX/SwiftUICatalog

をクローンしてください。

キャプチャをする流れ

SwiftUIそのものにはまだViewからイメージデータに変換するメソッドはありません。
しかしUIKitではImageContextを作成してUIViewのCALayerをレンダーすることでUIViewを画像データに変換することはできます。
そしてSwiftUIはUIHostingControllerを通してSwiftUIからUIViewに変換できます。

これを利用すると、

  • SwiftUIのViewをUIViewに変換
  • UIViewでキャプチャする
  • SwiftUIからキャプチャ画像を取得する

という手順を踏めばSwiftUIのViewを画像データに変換することができるのです。

お絵かきアプリ

今回のお絵かきアプリのキャプチャです。

----------2019-12-15-23.52.39

CanvasViewに指を触れると文字がかけるViewです。
下の「保存」ボタンを押すとキャプチャがとられる仕組みです。

SwiftUIのViewをUIViewに変換

SwiftUIのViewをUIViewに変換します。
コードは次のとおりです。

extension DrawEditor {
    func captureCanvas(canvasRect: CGRect,
                       endedDrawPoints: [DrawPoints],
                       tmpDrawPoints: DrawPoints,
                       startPoint: CGPoint,
                       selectedColor: DrawType) -> UIImage {

        let window = UIWindow(frame: CGRect(origin: canvasRect.origin,
                                            size: canvasRect.size))
        let canvas =  Canvas(endedDrawPoints: .constant(endedDrawPoints),
                             tmpDrawPoints: .constant(tmpDrawPoints),
                             startPoint: .constant(startPoint),
                             selectedColor: .constant(selectedColor),
                             canvasRect: .constant(canvasRect))
        let hosting = UIHostingController(rootView: canvas)
        hosting.view.frame = window.frame
        window.addSubview(hosting.view)
        window.makeKeyAndVisible()
        return hosting.view.renderedImage
    }
}

引数が多くてごちゃごちゃしていますがUIWindowCanvasViewの大きさのrectで作成します。
次に引数のパラメーター、これにはユーザーがドローイングした情報がはいっています、でCanvasViewを作成してUIHostingControllerでSwiftUIをUIViewControllerに変換します。あとはwindowにこのView Controllerを貼り付けてView Controllerのviewに対してrenderedImageを実行します。

UIViewでキャプチャする方法

ではまずUIViewでキャプチャする方法をみていきます。
次のようなextensionを作成しました。

extension UIView {
    var renderedImage: UIImage {
        // rect of capure
        let rect = self.bounds
        // create the context of bitmap
        UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
        let context: CGContext = UIGraphicsGetCurrentContext()!
        self.layer.render(in: context)
        // get a image from current context bitmap
        let capturedImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()
        return capturedImage
    }
}

UIGraphicsBeginImageContextWithOptionsでUIViewのサイズのコンテキストを作成し、CGContextを取り出して、self.layer.render(in: context)でコンテキストにUIViewのレイヤーを転写します。そしてUIGraphicsGetImageFromCurrentImageContextでUIImageとして画像を取得しています。

SwiftUIからキャプチャ画像を取得する

ButtonのactionでSwiftUIのキャプチャをとってみます。

Button(action: {
    let image = self.captureCanvas(canvasRect: self.canvasRect, endedDrawPoints: self.endedDrawPoints, tmpDrawPoints: self.tmpDrawPoints, startPoint: self.startPoint, selectedColor: self.selectedColor)
    print(image)

}) { Text("保存")
}

「保存」ボタンを押すとキャプチャ画像が取得されます。

キャンバスの座標情報の受け渡し

キャンバスの座標情報はそのままでは親Viewは知り得ない情報なので、子Viewから親Viewに座標情報を受け渡す必要があります。
プロパティにcanvasRectを追加し、CanvasViewを作成する際に$canvasRectで渡します。

struct DrawEditor: View {
    ...
    @State var canvasRect: CGRect = .zero
    var body: some View {
    VStack {
        Canvas(endedDrawPoints: $endedDrawPoints,
               tmpDrawPoints: $tmpDrawPoints,
               startPoint: $startPoint,
               selectedColor: $selectedColor,
               canvasRect: $canvasRect)

Canvasの方ではGeometryReaderをトップレベルのビューにし、クロージャーから渡された座標情報を.onAppearメソッドでcanvasRectに代入します。

struct Canvas: View {
    ...
    @Binding var canvasRect: CGRect
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
            .....
            .onAppear {
                    self.canvasRect = geometry.frame(in: .global)
            }
        }
    }

すると親ビューにcanvasRectの情報が渡るのでこの座標情報からキャプチャをとることができます。

つまったこと

最初実装した際にはキャンバスのCGRectのみを使い、UIHostingControllerselfを渡して実装していました。

extension DrawEditor {
    func captureCanvas(canvasRect: CGRect) -> UIImage {

        let window = UIWindow(frame: CGRect(origin: canvasRect.origin,
                                            size: canvasRect.size))
        let hosting = UIHostingController(rootView: self)
        hosting.view.frame = window.frame
        window.addSubview(hosting.view)
        window.makeKeyAndVisible()
        return hosting.view.renderedImage
    }
}

するとself.layer.render(in: context)でエラーになりました。

----------2019-12-16-0.23.21

Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)というエラーでアプリが落ちてしまいます。
正直このエラーの原因がわからない状態です。子のViewの座標情報を親Viewからレンダリングしているのが悪かったのでしょうか?UIKitとSwiftUIの橋渡しでイメージコンテキストがおかしくなっているのかもしれません。

なので、先程紹介したとおり、「キャンバスのデータは保持しておいて、Canvasを新たに作り直してキャプチャする」という方式を取りました。

let canvas =  Canvas(endedDrawPoints: .constant(endedDrawPoints),
                     tmpDrawPoints: .constant(tmpDrawPoints),
                     startPoint: .constant(startPoint),
                     selectedColor: .constant(selectedColor),
                     canvasRect: .constant(canvasRect))
let hosting = UIHostingController(rootView: canvas)

まとめ

  • SwiftUIだけだとViewのキャプチャはできない
  • SwiftUIをUIViewに変換してUIViewでキャプチャをする
  • SwiftUIでselfを渡すとself.layer.render(in: context)でエラー。Viewの情報を外から渡して作り直そう。

宣伝

一冊でSwiftUIの仕組みがわかる「SwiftUI実践入門」Boothで発売中です。

https://personal-factory.booth.pm/items/1579464

Author image

About Sato Takeshi

  • Tokyo, Japan