iOSで簡単に書類スキャンする


iOSで書類をスキャンしたいと思った時に、なんか小難しいことしないとなのかなと漠然と思いつつ調べていたら、想像以上に簡単にできそうなAPIが見つかりました。
`VNDocumentCameraViewController`が VisionKit フレームワークに iOS13 から登場していて技術調査がてら試しに使ってみたので、使用方法など記事にしてみます。

https://developer.apple.com/documentation/visionkit/vndocumentcameraviewcontroller

使い方はめちゃくちゃ簡単で、`VNDocumentCameraViewController`をインスタンス化して、名前の通り`UIViewController`なので`present`なり`push`なりで表示するだけで、以下のような UI を提供してくれます。

![書類スキャンでも.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/09398e48-1016-4139-993a-f1b5ef3395cf.gif)

注意点として、カメラを使用するので、Info.plist に`Privacy - Camera Usage Description`を加えておく必要があります。
これがないと、`VNDocumentCameraViewController`表示時にクラッシュします。

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/7cdf6cdc-4d9a-4abc-b042-e94d09acc795.png" />

画像の取得などは、`VNDocumentCameraViewControllerDelegate`で制御することになります。

https://developer.apple.com/documentation/visionkit/vndocumentcameraviewcontrollerdelegate

# 具体的な実装内容 


```swift
import SwiftUI
import VisionKit

struct ScannerView: View {
    
    @Environment(\.dismiss) var dismiss
    @State var scanModel: ScanModel?
    
    var body: some View {
        Scanner { model in
            print("title: \(model.title)")
            scanModel = model
        } onError: { error in
            print("onError: \(error)")
            dismiss()
        } onCancel: {
            print("onCancel")
            dismiss()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .fullScreenCover(item: $scanModel) { model in
            ScanImagePreviewView(previewImage: model.images.first!)
        }
    }
}

struct Scanner: UIViewControllerRepresentable {

    private let onFinish: (ScanModel) -> Void
    private let onCancel: () -> Void
    private let onError: (Error) -> Void

    init(onFinish: @escaping (ScanModel) -> Void,
         onError: @escaping (Error) -> Void,
         onCancel: @escaping () -> Void = {}) {

        self.onFinish = onFinish
        self.onCancel = onCancel
        self.onError = onError
    }

    func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
        let viewController = VNDocumentCameraViewController()
        viewController.delegate = context.coordinator
        return viewController
    }

    func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {}

    func makeCoordinator() -> ScannerCoordinator {

        return ScannerCoordinator(onFinish: onFinish, onCancel: onCancel, onError: onError)
    }
}

final class ScannerCoordinator: NSObject, VNDocumentCameraViewControllerDelegate {

    private let onFinish: (ScanModel) -> Void
    private let onCancel: () -> Void
    private let onError: (Error) -> Void

    init(onFinish: @escaping (ScanModel) -> Void,
         onCancel: @escaping () -> Void,
         onError: @escaping (Error) -> Void) {

        self.onFinish = onFinish
        self.onCancel = onCancel
        self.onError = onError
    }

    func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
        onCancel()
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
        var images: [UIImage] = []
        for page in 0..<scan.pageCount {
            images.append(scan.imageOfPage(at: page))
        }

        let scanModel = ScanModel(title: scan.title, images: images)
        onFinish(scanModel)
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: any Error) {
        onError(error)
    }
}

struct ScanModel {
    let title: String
    let images: [UIImage]
}
```

`VNDocumentCameraViewController`は`UIViewController`なので、SwiftUI で使うには`UIViewControllerRepresentable`でラップしています。

```swift
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
        let viewController = VNDocumentCameraViewController()
        viewController.delegate = context.coordinator
        return viewController
    }

    func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {}

    func makeCoordinator() -> ScannerCoordinator {

        return ScannerCoordinator(onFinish: onFinish, onCancel: onCancel, onError: onError)
    }
```

`ScannerCoordinator`に`VNDocumentCameraViewControllerDelegate`を準拠させ、
`makeCoordinator`メソッドで`ScannerCoordinator`をインスタンス化して、
`makeUIViewController`メソッドで`context`から`ScannerCoordinator`を取り出して、`delegate`として設定することで、Delegate を登録します。

```swift
final class ScannerCoordinator: NSObject, VNDocumentCameraViewControllerDelegate {

    private let onFinish: (ScanModel) -> Void
    private let onCancel: () -> Void
    private let onError: (Error) -> Void

    init(onFinish: @escaping (ScanModel) -> Void,
         onCancel: @escaping () -> Void,
         onError: @escaping (Error) -> Void) {

        self.onFinish = onFinish
        self.onCancel = onCancel
        self.onError = onError
    }

    func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
        onCancel()
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
        var images: [UIImage] = []
        for page in 0..<scan.pageCount {
            images.append(scan.imageOfPage(at: page))
        }

        let scanModel = ScanModel(title: scan.title, images: images)
        onFinish(scanModel)
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: any Error) {
        onError(error)
    }
}
```

`VNDocumentCameraViewControllerDelegate`では、スキャン終了、キャンセル、エラーの 3 つのイベントを制御することができます。

### スキャン終了

```swift
func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
        var images: [UIImage] = []
        for page in 0..<scan.pageCount {
            images.append(scan.imageOfPage(at: page))
        }

        let scanModel = ScanModel(title: scan.title, images: images)
        onFinish(scanModel)
    }
```

https://developer.apple.com/documentation/visionkit/vndocumentcameraviewcontrollerdelegate/documentcameraviewcontroller(_:didfinishwith:)

`VNDocumentCameraScan`を取得することができ、この中にスキャンした画像の情報が入っています。

https://developer.apple.com/documentation/visionkit/vndocumentcamerascan

上記例では、`VNDocumentCameraScan`から自前の`ScanModel`というモデルクラスに変換して、`onFinish`で SwiftUI 側へ画像データを渡しています。

このメソッドが呼ばれるタイミングは、スキャン後に活性される"Save"ボタン押下時になります。(てっきりスキャン完了時によばれるものとおもっていた、、)

<img width="200px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/83ca4802-fac3-4353-89fb-287ac0e201d5.png" />

### キャンセル

```swift
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
        onCancel()
    }
```

https://developer.apple.com/documentation/visionkit/vndocumentcameraviewcontrollerdelegate/documentcameraviewcontrollerdidcancel(_:)

そのままだけど、キャンセル時に呼ばれるメソッドです。
"Cancel"ボタン押下でアラートが表示されるので、そこで"Discard"を押下すると、このメソッドが呼ばれます。

<img width="200px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/83ca4802-fac3-4353-89fb-287ac0e201d5.png" />
<img width="200px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/a404e536-a672-4889-99df-d71b73042d8b.png" />

### エラー

```swift
func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: any Error) {
        onError(error)
    }
```

https://developer.apple.com/documentation/visionkit/vndocumentcameraviewcontrollerdelegate/documentcameraviewcontroller(_:didfailwitherror:)

これもそのままで、スキャン中にエラーが発生したときに呼ばれます。

## カスタマイズ性

ここまで見てきて、思うのはカスタマイズ性があるのだろうかというところが気になります。
delegate メソッドが呼ばれるタイミングがちょっと微妙(主にスキャン終了のタイミングが)なので、できればカスタマイズしたいですよね！
例えば、一回めにスキャンした書類をキャプチャときにスキャンを終了したいなどの時に、上記の delegate メソッドでは対応できないです。

ただ結論として、カスタマイズはできない！とフォーラムでガッツリ回答されていました。
残念、、

https://developer.apple.com/forums/thread/772703

カスタマイズしたいなら、Vision フレームワークと CoreImage を使うことで自前実装するしかないとのことです

## 使い所

カスタマイズ性はないとはいえ、実装が簡単な割に相当リッチな機能を提供できると思います。
MVP として素早く書類スキャン機能を実装したい時に使う分には全然ありだと思いました。
MVP でウケが良かったら、自前実装で使いやすくするみたいにすると、開発側としてもビジネス側も嬉しくなりそうですね。