iOS/SwiftUI

[iOS] VisionKit로 문서 스캔하기

yanni13 2025. 2. 27. 00:01

 

오늘은 문서 스캔 기능을 개발하기 위해 사용되는 프레임워크인 visionkit에 대해 알아보고

이를 프로젝트에 어떻게 적용했는지에 대해 설명하고자 한다.

 

 

 

✏️ VisionKit이란?

 

visionkit는 기기의 카메라를 사용하여 이미지를 인식하여 OCR기능을 활용할 수 있도록 apple에서 제공하는 프레임워크 중에 하나이다.

주로 사용자 카메라를 활용하여 문서를 스캔하는 데 사용되거나, 특정 문자를 인식하는 데 사용된다.

 

이 중에서 나는 문서 스캔에 대한 기능을 구현하고자 하였다.

 

 

1️⃣ VNDocumentCameraViewController

사용자가 기기 카메라를 통해 문서를 찍었을 때 곧바로 문서를 스캔하도록 하는 기능은 아이폰 유저라면 카메라 앱이나 메모 앱을 통해서 경험해 보았을 것이다.

이 기능을 제공해주는 것이 VNDocumentCameraViewController이다.

 

VNDocumentCameraViewController는 사용자가 카메라를 통해 문서를 촬영하고 자동으로 스캔할 수 있으며 스캔이 완료된 후에는 페이지 번호별로 결과 이미지까지 제공하는 Visionkit에서 제공하는 뷰컨트롤러이다.

 

하지만 UIViewController을 상속받고 있기 때문에 SwiftUI에서 사용하려면 UIViewControllerRepresentable을 감싸서 사용해주어야 한다.

 

 

 

UIViewControllerRepresentable이 구현되어 있는 프로토콜을 자세히 살펴보면, View를 상속받고 있는 것을 알 수 있다.

 

 

SwiftUI는 뷰를 띄우기 위해서 기본 뷰들이 View프로토콜을 따르지만 UIKitController는 UIKit에서 앱의 뷰 계층을 관리하는 class이기 때문에 SwiftUI에서 직접적으로 사용할 수 없다.

 

 

따라서 UIKit의 뷰 컨트롤러를 SwiftUI에서 나타날 수 있도록 하는 UIViewControllerRepresentable를 사용해 주는 것이다.

SwiftUI의 뷰와 UIKit를 연결해주는 중간 다리 정도로 이해하면 될 거 같다!

 

import SwiftUI
import VisionKit

struct DocumentScannerView: UIViewControllerRepresentable {
    @Environment(\.presentationMode) var presentationMode
    var onScanCompleted: ([UIImage]) -> Void //문서 스캔이 완료된 후를 실행할 클로저
    @State private var scannedImages: [UIImage] = [] //스캔된 이미지 목록을 저장하는 변수

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

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

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

 

UIViewControllerRepresentable를 통해 VNDocumentCameraViewController를 사용해주고 난 후 대략적인 코드 구성 모습이다.

 

문서 스캔을 개발하기 위한 3가지의 핵심 함수의 기능이다. 모두 공통적으로 UIViewControllerRepresentable을 사용하기 위해 구현되어야 하는 메서드들이다.

  • makeUIViewController: VNDocumentCameraViewController를 생성하여 생성함.
  • updateUIViewController: SwiftUI에서 UIViewController의 상태를 업데이트할 때 호출된다.
  • makeCoordinator: Coordinator 클래스의 인스턴스를 생성하여 반환하며 UIKit의 델리게이트 패턴을 SwiftUI에 사용하기 위해 필요함.

 

UIViewControllerRepresentable는 SwiftUI에서 UIKit을 사용 가능하도록 도와주는 연결다리라고 앞에 설명했었다. 우리가 사용하려는 문서 스캔을 하는 VNDocumentCameraViewController 역시 UIKit 기반의 컴포넌트 이기 때문에 SwiftUI에서 사용하기 위해서는 UIKit의 델리게이트 패턴을 사용해야 한다.

 

SwiftUI에서는 @State, @Binding을 통해서 UI를 자동으로 업데이트하는 방식이지만, UIKit에서는 델리게이트 패턴을 통해서 이벤트 기반 콜백을 사용하는 구조이기 때문이다.

 

델리게이트 패턴이란?  객체가 자신의 기능을 다른 객체에게 위임하는 디자인 패턴이다.

ex) A객체가 해야 할 일을 B에게 위임하여 실행하도록 하여 결합도를 낮추고 확장성을 높이는 방식

 

 

따라서 SwiftUI코드로 UIKit기반 컴포넌트를 사용하려면, 직접 연결할 방법이 없기 때문에

화면을 업데이트 해주는 델리게이트 패턴을 준수해야 하는데 Coordinator 클래스를 사용해서 이벤트를 연결하도록 해야 한다.

 

 

2️⃣ Coordinator 클래스

 

앞에서 설명했듯이 makeCoordinator 함수를 보면 Coordinator 클래스의 인스턴스를 생성하고 반환하고 있다. 

그래서 델리게이트 패턴을 구현하는 Coordinator 클래스에 대해서 알아보자.

 

class Coordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate {
        var parent: DocumentScannerView

        init(_ parent: DocumentScannerView) {
            self.parent = parent
        }

        // 스캔 완료 시 
        @MainActor func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
            var scannedImages: [UIImage] = []
            for i in 0..<scan.pageCount {
                scannedImages.append(scan.imageOfPage(at: i))
            }
            parent.onScanCompleted(scannedImages)
            parent.presentationMode.wrappedValue.dismiss()
        }

        // 스캔 취소 시
        @MainActor func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
            parent.presentationMode.wrappedValue.dismiss()
        }

        // 스캔 오류 시
        @MainActor func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) {
            print("Document scanning failed: \(error.localizedDescription)")
            parent.presentationMode.wrappedValue.dismiss()
        }
    }

 

Coordinator 클래스는 델리게이트 패턴을 구현하기 위하여 스캔을 완료했을 때, 스캔을 취소했을 때, 스캔 중 오류가 났을 때에 대한 콜백 메서드를 정의함으로써 구현한 것이다.

 

@MainActor를 사용해서 함수 전체를 메인 스레드에서 안전하게 UI를 업데이트하고 다른 스레드에서 수행되는 작업과의 동기화를 관리하도록 하여 Swift Concurrency의 일관성을 유지하도록 하였다. 

(DispatchQueue도 메인스레드에서 실행된다는 건 똑같은데 MainActor가 컴파일러 기반으로 함수 전체가 메인스레드에서 실행되기 때문에 사용해 보았다. 이 둘의 차이점도 다음 블로그 주제로 포스팅해야겠다.)

 

스캔 완료 시 스캔한 모든 페이지를 UIImage 타입의 배열로 저장하며 parent.onScanCompleted()를 호출하여 스캔된 이미지를 상위 뷰인 ScanView로 전달하도록 한다.

 

그리고 스캔이 완료됐을 경우 sheet가 닫히도록 구현하였다.

 

 

3️⃣ View랑 연동

 

이제 기기 카메라를 통해 문서 스캔하는 기능을 구현하였다면, 메인 뷰에서 문서 스캔 뷰가 열리도록 연결해 주면 끝이다!

 

상위뷰인 ScanView에서 scan 버튼을 눌렀을 때 viewModel에 있는 isShowingScanner가 활성화되도록 하고, 이를 ScanView에서 다시 받아서 fullScreenView로 DocumentScannerView를 띄우도록 구성하였다.

 

.fullScreenCover(isPresented: $viewModel.isShowingScanner) {
    DocumentScannerView { scannedImages in
         self.viewModel.scannedImages = scannedImages
    }
}

 

 

 

 

 

 

 

⤵️ 전체 코드는 아래와 같다.

더보기

 

import SwiftUI
import VisionKit

struct DocumentScannerView: UIViewControllerRepresentable {
    @Environment(\.presentationMode) var presentationMode
    var onScanCompleted: ([UIImage]) -> Void
    @State private var showDrawingEditor = false
    @State private var scannedImages: [UIImage] = []

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

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

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

    class Coordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate {
        var parent: DocumentScannerView

        init(_ parent: DocumentScannerView) {
            self.parent = parent
        }

        @MainActor func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
            var scannedImages: [UIImage] = []
            for i in 0..<scan.pageCount {
                scannedImages.append(scan.imageOfPage(at: i))
            }
            parent.onScanCompleted(scannedImages)
            parent.presentationMode.wrappedValue.dismiss()
        }

        @MainActor func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
            parent.presentationMode.wrappedValue.dismiss()
        }

        @MainActor func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) {
            print("Document scanning failed: \(error.localizedDescription)")
            parent.presentationMode.wrappedValue.dismiss()
        }
    }
}

 

 

동작과정

 

 

실제로 폰과 연결해서 빌드해 봤을 때 정보를 제대로 인식가능하며, 자동으로 인식되는 경우에는 알아서 저장까지 된다!

또는 원하는 이미지로 잘라야 하거나 비율 조정이 필요하다면 문서를 스캔하고 저장하기 전까지 스캔된 이미지 한해서 편집이 가능하다.

 


 

참고

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

https://framios.tistory.com/64