+3

Quét văn bản và mã vạch sử dụng VisionKit trong SwiftUI - iOS

Qua bài này, các bạn sẽ có thể thêm tính năng mở camera để quét văn bản (text), mã vạch (barcode), ... vào ứng dụng của mình một cách dễ dàng.


Giới thiệu DataScannerViewController

Từ iOS 16, Apple giới thiệu DataScannerViewController - một api mạnh mẽ và dễ dàng tích hợp chức năng scanning sử dụng chính camera của device. DataScannerViewController có thể scan được nhiều loại data như chữ và barcode, ... Mặc dù rất hữu ích, nhưng tài liệu hoặc example về DataScannerViewController tương đối ít, điều này có thể khiến việc tích hợp vào ứng dụng của bạn gặp thách thức. Qua bài viết này, bạn có thể nhanh chóng học cách sử dụng để thêm các tính năng sáng tạo, tiện nghi vào ứng dụng của mình.

DataScannerViewController kế thừa từ UIViewController, có thể override các phương thức sau để tùy chỉnh thêm

image.png Tương tự như tính năng Live Text trong ứng dụng Camera. API này cung cấp nhiều tính năng hữu ích như:

  • Hướng dẫn người dùng: hiển thị các chỉ dẫn trực quan để người dùng biết họ đang quét nội dung gì.
  • Highlight: văn bản hoặc mã QR được nhận diện sẽ được đánh dấu trực tiếp trên màn hình để người dùng dễ nhận biết.
  • Pinch-to-Zoom: tính năng phóng to và thu nhỏ, giúp việc quét chính xác hơn khi cần.

Cách sử dụng DataScannerViewController

Yêu cầu quyền truy cập camera (bắt buộc)

Bạn cần yêu cầu quyền sử dụng camera bằng cách thêm khóa NSCameraUsageDescription vào file Info.plistcủa project. Khóa này cung cấp lý do sử dụng camera, được hiển thị cho người dùng lần đầu tiên khi ứng dụng yêu cầu quyền truy cập.

<key>NSCameraUsageDescription</key>
<string>Chúng tôi cần quyền truy cập vào camera để quét mã QR và văn bản.</string>

Kiểm tra tính khả dụng

Trước khi hiển thị DataScannerViewController, hãy đảm bảo rằng thiết bị hỗ trợ và nó có thể sử dụng được. Sử dụng thuộc tính isSupported và isAvailable để kiểm tra:

if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
    // Tiến hành khởi tạo DataScannerViewController
}

Tạo DataScannerViewController

Khởi tạo một đối tượng DataScannerViewController. API này cho phép sử dụng camera của thiết bị để quét các loại dữ liệu như văn bản, mã vạch hoặc mã QR. Bạn có thể tùy chỉnh các loại dữ liệu muốn quét.

var scannerViewController: DataScannerViewController = DataScannerViewController(
        recognizedDataTypes: [.text(), .barcode()],
        qualityLevel: .accurate,
        recognizesMultipleItems: false,
        isHighFrameRateTrackingEnabled: false,
        isHighlightingEnabled: true
    )

Các loại data được quét như Barcode/QRCode được liệt kê ở đây: https://developer.apple.com/documentation/vision/vnbarcodesymbology

Implement các phương thức delegate

Để xử lý dữ liệu quét được, cần triển khai các phương thức của DataScannerViewControllerDelegate. Điều này cho phép bạn xử lý các mục được nhận diện bởi máy quét, chẳng hạn như văn bản hoặc mã QR. Ví dụ, khi người dùng nhấn vào một mục được nhận diện, ứng dụng có thể kích hoạt các hành động cụ thể (ví dụ: mở URL, gọi số điện thoại).

func dataScanner(_ scanner: DataScannerViewController, didTapOn item: RecognizedItem) {
    switch item {
    case .barcode(let barcode):
        if let payload = barcode.payloadStringValue {
            // Xử lý mã QR hoặc mã vạch
            openURL(payload)
        }
    case .text(let text):
        // Xử lý văn bản được nhận diện
        print("Văn bản nhận diện: \(text.transcript)")
    default:
        break
    }
}

Hiển thị:

Cuối cùng, hiển thị DataScannerViewController để cho phép người dùng quét:

present(scannerViewController, animated: true)

Sử dụng trong SwiftUI

Mặc dù tiếp cận DataScannerViewController trong UIKit tương đối đơn giản, đối với dự án sử dụng SwiftUI, cần cách tiếp cận khác đi 1 chút, cùng theo dõi ví dụ dưới đây nhé:

import SwiftUI
import VisionKit

@MainActor // đảm bảo mọi update trên UI đều được thực hiện ở Mainthread
// Sử dụng UIViewControllerRepresentable để tích hợp một view controller từ UIKit (DataScannerViewController) vào SwiftUI
struct DocumentScannerView: UIViewControllerRepresentable {
    
    // khởi tạo sanning view
    var scannerViewController: DataScannerViewController = DataScannerViewController(
        recognizedDataTypes: [.text(), .barcode()],
        qualityLevel: .accurate,
        recognizesMultipleItems: false,
        isHighFrameRateTrackingEnabled: false,
        isHighlightingEnabled: true
    )
    
    func makeUIViewController(context: Context) -> DataScannerViewController {
        scannerViewController.delegate = context.coordinator
        
        // Add a button to start scanning
        let scanButton = UIButton(type: .system)
        scanButton.backgroundColor = UIColor.systemBlue
        scanButton.setTitle("Start Scan", for: .normal)
        scanButton.setTitleColor(UIColor.white, for: .normal)
        scanButton.addTarget(context.coordinator, action: #selector(Coordinator.startScanning(_:)), for: .touchUpInside)
        scannerViewController.view.addSubview(scanButton)
        
        // Set up button constraints
        scanButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            scanButton.centerXAnchor.constraint(equalTo: scannerViewController.view.centerXAnchor),
            scanButton.bottomAnchor.constraint(equalTo: scannerViewController.view.safeAreaLayoutGuide.bottomAnchor, constant: -20)
        ])
        
        return scannerViewController
    }
    
    // Tạo một đối tượng Coordinator để xử lý các sự kiện và tương tác giữa SwiftUI và UIKit. 
    // Coordinator giúp giao tiếp giữa DataScannerViewController và SwiftUI.
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
// Class quản lý các tương tác giữa DataScannerViewController và SwiftUI.
    class Coordinator: NSObject, DataScannerViewControllerDelegate {
        var parent: DocumentScannerView
        var roundBoxMappings: [UUID: UIView] = [:]
        
        init(_ parent: DocumentScannerView) {
            self.parent = parent
        }
        
        // DataScannerViewControllerDelegate - methods starts here
        func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            //ToDo
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            //ToDo
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            //ToDo
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
            //ToDo
        }
        // DataScannerViewControllerDelegate - methods ends here
        
        // Phương thức này bắt đầu quá trình quét khi người dùng nhấn vào nút "Start Scan". Nó gọi startScanning trên scannerViewController.
        @objc func startScanning(_ sender: UIButton) {
            try? parent.scannerViewController.startScanning()
        }
    }
}

Customize Overlay view

DataScannerViewController API cung cấp quyền truy cập vào overlayContainerView, cho phép các nhà phát triển tùy chỉnh giao diện bằng cách thêm các lớp phủ lên trên các văn bản hoặc mã vạch đã được nhận diện. Ví dụ, bạn có thể tạo các khung bao quanh hoặc thêm thông tin bổ sung về các đối tượng đã được nhận diện.

scannerViewController.overlayContainerView.addSubview(UIView())

Ngoài overlayContainerView, DataScannerViewController còn cung cấp nhiều thuộc tính và phương thức có thể tùy chỉnh khác. Bạn có thể thay đổi giao diện của văn bản hướng dẫn xuất hiện trên các văn bản hoặc mã vạch đã được nhận diện, và tùy chỉnh màu giao diện của máy quét. Thêm vào đó, bạn có thể thêm các view tùy chỉnh để cung cấp phản hồi chi tiết hơn cho người dùng, như làm nổi bật các phần cụ thể của văn bản hoặc mã vạch đã quét.

DataScannerViewController cũng cho phép bạn điều chỉnh hành vi quét tùy theo trường hợp sử dụng của bạn. Bạn có thể chỉ định loại dữ liệu nhận diện , chất lượng quét, có nhận diện nhiều đối tượng cùng lúc hay không, và có bật theo dõi tốc độ khung hình cao hay không.

static let textDataType: DataScannerViewController.RecognizedDataType = .text(
        languages: [
            "en-US",
            "ja_JP"
        ]
    )
var scannerViewController: DataScannerViewController = DataScannerViewController(
    recognizedDataTypes: [DocumentScannerView.textDataType, .barcode()],
    qualityLevel: .accurate,
    recognizesMultipleItems: false,
    isHighFrameRateTrackingEnabled: false,
    isHighlightingEnabled: false
)

Cùng xem code hoàn chỉnh với hàm processItem để lấy các giá trị được quét từ máy ảnh.

import Foundation
import SwiftUI
import VisionKit

@MainActor
struct DocumentScannerView: UIViewControllerRepresentable {
    
    static let startScanLabel = "Start Scan"
    static let stopScanLabel = "Stop Scan"
    
    static let textDataType: DataScannerViewController.RecognizedDataType = .text(
        languages: [
            "en-US",
            "ja_JP"
        ]
    )
    var scannerViewController: DataScannerViewController = DataScannerViewController(
        recognizedDataTypes: [DocumentScannerView.textDataType, .barcode()],
        qualityLevel: .accurate,
        recognizesMultipleItems: false,
        isHighFrameRateTrackingEnabled: false,
        isHighlightingEnabled: false
    )
   
    func makeUIViewController(context: Context) -> DataScannerViewController {
        scannerViewController.delegate = context.coordinator
        
        // Add a button to start scanning
        let scanButton = UIButton(type: .system)
        scanButton.backgroundColor = UIColor.systemBlue
        scanButton.setTitle(DocumentScannerView.startScanLabel, for: .normal)
        scanButton.setTitleColor(UIColor.white, for: .normal)
        
        var config = UIButton.Configuration.filled()
        config.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
        scanButton.configuration = config
        
        scanButton.addTarget(context.coordinator, action: #selector(Coordinator.startScanning(_:)), for: .touchUpInside)
        scanButton.layer.cornerRadius = 5.0
        scannerViewController.view.addSubview(scanButton)
        
        // Set up button constraints
        scanButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            scanButton.centerXAnchor.constraint(equalTo: scannerViewController.view.centerXAnchor),
            scanButton.bottomAnchor.constraint(equalTo: scannerViewController.view.safeAreaLayoutGuide.bottomAnchor, constant: -20)
        ])
        
        return scannerViewController
    }
    
    func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {
        // Update any view controller settings here
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    class Coordinator: NSObject, DataScannerViewControllerDelegate {
        var parent: DocumentScannerView
        var roundBoxMappings: [UUID: UIView] = [:]
        
        init(_ parent: DocumentScannerView) {
            self.parent = parent
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            processAddedItems(items: addedItems)
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            processRemovedItems(items: removedItems)
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            processUpdatedItems(items: updatedItems)
        }
        
        func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
            processItem(item: item)
        }
        
        
        func processAddedItems(items: [RecognizedItem]) {
            for item in items {
                processItem(item: item)
            }
        }
        
        func processRemovedItems(items: [RecognizedItem]) {
            for item in items {
                removeRoundBoxFromItem(item: item)
            }
        }
        
        func processUpdatedItems(items: [RecognizedItem]) {
            for item in items {
                updateRoundBoxToItem(item: item)
            }
        }

        func processItem(item: RecognizedItem) {
            switch item {
            case .text(let text):
                print("Text Observation - \(text.observation)")
                print("Text transcript - \(text.transcript)")
                let frame = getRoundBoxFrame(item: item)
                // Adding the round box overlay to detected text
                addRoundBoxToItem(frame: frame, text: text.transcript, item: item)
            case .barcode:
                break
            @unknown default:
                print("Should not happen")
            }
        }
        
        func addRoundBoxToItem(frame: CGRect, text: String, item: RecognizedItem) {
            //let roundedRectView = RoundRectView(frame: frame)
            let roundedRectView = RoundedRectLabel(frame: frame)
            roundedRectView.setText(text: text)
            parent.scannerViewController.overlayContainerView.addSubview(roundedRectView)
            roundBoxMappings[item.id] = roundedRectView
        }
        
        func removeRoundBoxFromItem(item: RecognizedItem) {
            if let roundBoxView = roundBoxMappings[item.id] {
                if roundBoxView.superview != nil {
                    roundBoxView.removeFromSuperview()
                    roundBoxMappings.removeValue(forKey: item.id)
                }
            }
        }
        
        func updateRoundBoxToItem(item: RecognizedItem) {
            if let roundBoxView = roundBoxMappings[item.id] {
                if roundBoxView.superview != nil {
                    let frame = getRoundBoxFrame(item: item)
                    roundBoxView.frame = frame
                }
            }
        }
        
        func getRoundBoxFrame(item: RecognizedItem) -> CGRect {
            let frame = CGRect(
                x: item.bounds.topLeft.x,
                y: item.bounds.topLeft.y,
                width: abs(item.bounds.topRight.x - item.bounds.topLeft.x) + 15,
                height: abs(item.bounds.topLeft.y - item.bounds.bottomLeft.y) + 15
            )
            return frame
        }
        
        // Add this method to start scanning
        @objc func startScanning(_ sender: UIButton) {
            if sender.title(for: .normal) == startScanLabel {
                try? parent.scannerViewController.startScanning()
                sender.setTitle(stopScanLabel, for: .normal)
            } else {
                parent.scannerViewController.stopScanning()
                sender.setTitle(startScanLabel, for: .normal)
            }
        }
    }
    
    class RoundedRectLabel: UIView {
        let label = UILabel()
        let cornerRadius: CGFloat = 5.0
        let padding: CGFloat = 5
        var text: String = ""
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            
            // Configure the label
            label.textColor = .white
            label.font = UIFont.systemFont(ofSize: 10)
            label.textAlignment = .left
            label.numberOfLines = 0
            label.text = text
            label.translatesAutoresizingMaskIntoConstraints = false
            
            addSubview(label)
            
            // Add constraints for the label
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: topAnchor, constant: padding),
                label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding),
                label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding),
                label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding)
            ])
            
            // Configure the background
            backgroundColor = .magenta
            layer.cornerRadius = cornerRadius
            layer.opacity = 0.75
        }
        
        func setText(text: String) {
            label.text = text
            setNeedsDisplay()
        }
        
        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
}

Sử dụng trong SwiftUI

Rất đơn giản như sau:

import Foundation
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                Text("Scan a document")
                    .font(.title)
                    .padding()
                DocumentScannerView()
                    .navigationBarTitle("")
                    .navigationBarHidden(true)
            }
        }
    }
}

Sau đó thì tận hưởng thành quả của bạn thôi

image.pngimage.png


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí