Swift Tutorial: Build an iOS MRZ and VIN Barcode Scanner with Dynamsoft Capture Vision

The Dynamsoft GitHub page provides sample projects for learning iOS development with MRZ (Machine Readable Zone) and VIN (Vehicle Identification Number) scanning. Both sample projects are built using Storyboard. In this tutorial, we’ll use the official source code as a foundation to build an iOS application that supports both MRZ and VIN scanning using SwiftUI.

What you’ll build: A SwiftUI iOS app that scans both MRZ documents (passports and ID cards) and VIN barcodes using the Dynamsoft Capture Vision SDK, with a radio button UI to switch between scan modes at runtime.

Key Takeaways

  • Dynamsoft Capture Vision’s ScannerViewController can be wrapped in a UIViewControllerRepresentable to integrate seamlessly into a SwiftUI app with no UIKit boilerplate in the view layer.
  • MRZ and VIN scanning use different capture templates (ReadPassportAndId vs. ReadVINText) — switching between modes at runtime requires only a single ScannerConfig.mode property change.
  • VIN results are parsed from ParsedResultItem.parsedFields into a strongly-typed VINData model, giving structured access to WMI, VDS, model year, and serial number.
  • This architecture can be extended to any additional Dynamsoft recognition mode (barcodes, driver’s licences) with minimal structural changes to the scanner framework.

Common Developer Questions

  • How do I build an iOS MRZ and VIN barcode scanner in Swift using SwiftUI?
  • How do I parse MRZ and VIN data from Dynamsoft Capture Vision results in Swift?
  • How do I switch between MRZ and VIN scanning modes at runtime in an iOS app?

iOS MRZ/VIN Scanner Demo Video

Prerequisites

  • Free Trial License for Dynamsoft Capture Vision.
  • iOS MRZ Scanner: Includes both a framework and an application project. The framework wraps low-level APIs of Dynamsoft Capture Vision and provides a camera view, while the app demonstrates how to use the framework to scan MRZ and display results.
  • iOS VIN Scanner: Demonstrates how to scan VINs using Dynamsoft Capture Vision.

Make sure to request a license key and download both projects before getting started.

Step 1: Set Up the iOS MRZ/VIN Scanner Project in Xcode

  1. Create a new Xcode project using the App template and select SwiftUI as the interface.
  2. Go to File > Add Package Dependencies and add all required dependencies from the capture-vision-spm repository.
  3. Drag the MRZ scanner framework project into your new project’s Project Navigator.
  4. In your project settings under the General tab, locate the Frameworks, Libraries, and Embedded Content section. Ensure that the iOS MRZ Scanner framework is set to Embed & Sign, and verify that all other frameworks are correctly linked.

    Xcode framework dependency

  5. In the Signing & Capabilities tab, enable Automatically manage signing and select your development team. Xcode will automatically generate the signing certificate and provisioning profile for your app.
  6. In the Info tab, add a new key for Privacy - Camera Usage Description and provide a description for why your app needs camera access.
  7. Build the project to verify that the setup is complete and error-free.

Step 2: Design the Scanner UI

iOS MRZ/VIN Scanner UI

The UI consists of a radio button group for selecting the scan mode (MRZ or VIN), a text area for displaying scan results, and a button to start scanning.

  • The radio button group is implemented using a custom modeButton function, which creates a button with a circular indicator to show the selected mode.
  • The text area is a ScrollView that displays the scan results in a readable format.
  • The start scanning button launches the scanner when tapped.
import DynamsoftMRZScannerBundle
import SwiftUI

struct ContentView: View {
    @State private var scanResult: String = ""
    @State private var scanMode: ScanMode = .mrz

    var body: some View {
        VStack(spacing: 16) {
            // MARK: - Radio Group UI
            HStack(spacing: 24) {
                modeButton(title: "MRZ", mode: .mrz)
                modeButton(title: "VIN", mode: .vin)
            }
            .padding(.top, 16)

            ScrollView {
                Text(scanResult)
                    .font(.system(size: 20))
                    .padding(16)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.white)
                    .foregroundColor(.black)
                    .lineSpacing(4)
            }
            .background(Color.white)
            .cornerRadius(8)
            .shadow(radius: 2)

            Spacer()

            HStack(spacing: 8) {
                Button(action: {
                    presentScanner()

                }) {
                    Text("START SCANNING")
                        .font(.headline)
                        .foregroundColor(.white)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color.blue)
                        .cornerRadius(8)
                }
            }
        }
        .padding(16)
        .background(Color(UIColor.systemGroupedBackground))
    }

    @ViewBuilder
    func modeButton(title: String, mode: ScanMode) -> some View {
        Button(action: {
            scanMode = mode
            scanResult = ""  
        }) {
            HStack(spacing: 8) {
                ZStack {
                    Circle()
                        .stroke(Color.orange, lineWidth: 2)
                        .frame(width: 20, height: 20)
                    if scanMode == mode {
                        Circle()
                            .fill(Color.orange)
                            .frame(width: 10, height: 10)
                    }
                }
                Text(title)
                    .foregroundColor(.black)
                    .font(.system(size: 16, weight: .medium))
            }
        }
    }
}

Step 3: Open the MRZ and VIN Scanner View

The scanner view is implemented using UIKit in the framework project. To integrate it into a SwiftUI app, we create a UIViewControllerRepresentable wrapper around the scanner view controller. The current framework does not support VIN scanning by default, so we will extend it to handle both MRZ and VIN scanning.


struct ContentView: View {
    ...

    func presentScanner() {
        let config = ScannerConfig()
        config.license =
            "LICENSE-KEY"
        config.mode = scanMode

        var scannerView = MRZScannerView(config: config)
        scannerView.onScannedResult = { result in
            DispatchQueue.main.async {
                switch result.resultStatus {
                case .finished:
                    switch scanMode {
                    case .mrz:
                        let mrzResult = result as? MRZScanResult
                        if let data = mrzResult?.data {
                            self.scanResult +=
                                "Name: " + data.firstName + " " + data.lastName + "\n\n"
                            self.scanResult += "Sex: " + data.sex.capitalized + "\n\n"
                            self.scanResult += "Age: " + String(data.age) + "\n\n"
                            self.scanResult += "Document Type: " + data.documentType + "\n\n"
                            self.scanResult += "Document Number: " + data.documentNumber + "\n\n"
                            self.scanResult += "Issuing State: " + data.issuingState + "\n\n"
                            self.scanResult += "Nationality: " + data.nationality + "\n\n"
                            self.scanResult += "Date Of Birth: " + data.dateOfBirth + "\n\n"
                            self.scanResult += "Date Of Expire: " + data.dateOfExpire + "\n\n"
                        }
                    case .vin:
                        let vinResult = result as? VINScanResult
                        if let data = vinResult?.data {
                            self.scanResult += "VIN String: " + data.vinString + "\n\n"
                            self.scanResult += "WMI: " + data.wmi + "\n\n"
                            self.scanResult += "Region: " + data.region + "\n\n"
                            self.scanResult += "VDS: " + data.vds + "\n\n"
                            self.scanResult += "Check Digit: " + data.checkDigit + "\n\n"
                            self.scanResult += "Model Year: " + data.modelYear + "\n\n"
                            self.scanResult += "Manufacturer plant: " + data.plantCode + "\n\n"
                            self.scanResult += "Serial Number: " + data.serialNumber + "\n\n"
                        }
                    }
                case .canceled:
                    self.scanResult = "Scan canceled"
                case .exception:
                    self.scanResult = result.errorString ?? "Unknown error"
                @unknown default:
                    break
                }

                let rootVC = UIApplication.shared.connectedScenes
                    .compactMap { $0 as? UIWindowScene }
                    .flatMap { $0.windows }
                    .first { $0.isKeyWindow }?.rootViewController

                rootVC?.dismiss(animated: true, completion: nil)
            }
        }

        let rootVC = UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .first { $0.isKeyWindow }?.rootViewController

        rootVC?.present(
            UIHostingController(rootView: scannerView),
            animated: true,
            completion: nil
        )
    }
}

struct MRZScannerView: UIViewControllerRepresentable {
    let config: ScannerConfig
    var onScannedResult: ((ScanResultBase) -> Void)?

    func makeUIViewController(context: Context) -> ScannerViewController {
        let vc = ScannerViewController()
        vc.config = config
        vc.onScannedResult = onScannedResult
        return vc
    }

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

Step 4: Integrate VIN Scanning Into the Scanner View

The recognition mode is determined by the template name:

  • MRZ uses ReadPassportAndId
  • VIN uses ReadVINText

When scanning starts, the appropriate template name activates the desired recognition mode.

Define the Scan Mode Enum

In ScannerConfig.swift, add a new enum representing the scan mode. The mode property is used to determine which template to use for scanning.

public enum ScanMode {
    case mrz
    case vin
}

public class ScannerConfig: NSObject {
    ...
    public var mode: ScanMode = ScanMode.mrz
}

Parse VIN Recognition Results

In ScanResult.swift, create a ScanResultBase class that serves as the base class for all scan results. The MRZScanResult and VINScanResult classes inherit from this base class. The VINScanResult class contains properties specific to VIN scanning.

@objcMembers
@objc(DSScanResultBase)
public class ScanResultBase: NSObject {
    public let resultStatus: ResultStatus
    public let errorCode: Int
    public let errorString: String?

    init(resultStatus: ResultStatus, errorCode: Int = 0, errorString: String? = nil) {
        self.resultStatus = resultStatus
        self.errorCode = errorCode
        self.errorString = errorString
    }
}

public class MRZScanResult: ScanResultBase {
    public let data: MRZData?

    init(
        resultStatus: ResultStatus, mrzdata: MRZData? = nil, errorCode: Int = 0,
        errorString: String? = nil
    ) {
        self.data = mrzdata
        super.init(resultStatus: resultStatus, errorCode: errorCode, errorString: errorString)
    }
}

public class VINScanResult: ScanResultBase {
    public let data: VINData?

    init(
        resultStatus: ResultStatus, vindata: VINData? = nil, errorCode: Int = 0,
        errorString: String? = nil
    ) {
        self.data = vindata
        super.init(resultStatus: resultStatus, errorCode: errorCode, errorString: errorString)
    }
}

@objcMembers
@objc(DSVINData)
public class VINData: NSObject {
    public let vinString: String
    public let wmi: String
    public let region: String
    public let vds: String
    public let checkDigit: String
    public let modelYear: String
    public let plantCode: String
    public let serialNumber: String

    init(
        vinString: String, wmi: String, region: String, vds: String, checkDigit: String,
        modelYear: String, plantCode: String, serialNumber: String
    ) {
        self.vinString = vinString
        self.wmi = wmi
        self.region = region
        self.vds = vds
        self.checkDigit = checkDigit
        self.modelYear = modelYear
        self.plantCode = plantCode
        self.serialNumber = serialNumber
    }
}

Handle VIN Recognition in ScannerViewController

When the scanner view is opened, the UI is presented based on the selected scanning mode. If the mode is set to VIN, the result must be parsed and converted into a VINData object.

public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        dce.open()
        var name: String
        ...

        switch config.mode {
        case .mrz:
            name = "ReadPassportAndId"
        case .vin:
            name = "ReadVINText"
        }
        ...
        cvr.startCapturing(name) { isSuccess, error in
            if let error = error as? NSError, !isSuccess {
                self.onScannedResult?(
                    .init(
                        resultStatus: .exception, errorCode: error.code,
                        errorString: error.localizedDescription))
            }
        }
    }

private func setupUI() {
        ...

        switch config.mode {
        case .mrz:
            imageView.isHidden = !config.isGuideFrameVisible
            imageView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(imageView)

            let safeArea = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                imageView.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor),
                imageView.centerYAnchor.constraint(equalTo: safeArea.centerYAnchor),
                imageView.widthAnchor.constraint(
                    lessThanOrEqualTo: safeArea.widthAnchor, multiplier: 0.9),
                imageView.heightAnchor.constraint(
                    lessThanOrEqualTo: safeArea.heightAnchor, multiplier: 0.9),

                closeButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 20),
                closeButton.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 20),

                stackView.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor),
                stackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -50),
            ])
        case .vin:
            let region = Rect()
            region.top = 0.4
            region.bottom = 0.6
            region.left = 0.1
            region.right = 0.9
            region.measuredInPercentage = true
            try? dce.setScanRegion(region)
        }
    }

extension ScannerViewController: CapturedResultReceiver {

    public func onParsedResultsReceived(_ result: ParsedResult) {
        guard let item = result.items?.first else { return }
        stop()
        if config.isBeepEnabled {
            Feedback.beep()
        }
        switch config.mode {
        case .mrz:
            let mrzdata = convertToMRZData(item: item)
            let data = MRZScanResult(resultStatus: .finished, mrzdata: mrzdata)
            onScannedResult?(data)
        case .vin:
            let vindata = convertToVINData(item: item)
            let data = VINScanResult(resultStatus: .finished, vindata: vindata)
            onScannedResult?(data)
        }
    }

    private func convertToVINData(item: ParsedResultItem) -> VINData? {
        let parsedFields = item.parsedFields

        guard let vinString = parsedFields["vinString"] == nil ? "N/A" : parsedFields["vinString"],
            let wmi = parsedFields["WMI"] == nil ? "N/A" : parsedFields["WMI"],
            let region = parsedFields["region"] == nil ? "N/A" : parsedFields["region"],
            let vds = parsedFields["VDS"] == nil ? "N/A" : parsedFields["VDS"],
            let checkDigit = parsedFields["checkDigit"] == nil ? "N/A" : parsedFields["checkDigit"],
            let modelYear = parsedFields["modelYear"] == nil ? "N/A" : parsedFields["modelYear"],
            let plantCode = parsedFields["plantCode"] == nil ? "N/A" : parsedFields["plantCode"],
            let serialNumber = parsedFields["serialNumber"] == nil
                ? "N/A" : parsedFields["serialNumber"]
        else { return nil }

        let vinData = VINData(
            vinString: vinString, wmi: wmi, region: region, vds: vds,
            checkDigit: checkDigit, modelYear: modelYear, plantCode: plantCode,
            serialNumber: serialNumber)
        return vinData
    }
}

Complete the Scanner View for VIN Recognition

The final scanner view for VIN recognition is similar to the MRZ scanner view. However, it does not display the guide frame. In setupUI(), the guide frame is hidden when the scan mode is set to VIN, and a rectangular scan region is defined instead.

iOS VIN Scanner

Common Issues & Edge Cases

  • License key not set or expired: If the scanner launches but immediately returns an error, verify your Dynamsoft license key is correctly assigned in ScannerConfig and has not expired. Request a new trial license via the Dynamsoft website if needed.
  • VIN fields returning nil: Some VINs from older vehicles may omit optional fields. The convertToVINData method guards against nil by substituting "N/A" — ensure your UI handles these placeholder values gracefully rather than crashing.
  • Camera permission denied: If the scanner view appears blank, confirm that the Privacy - Camera Usage Description key is present in your app’s Info.plist. iOS silently blocks camera access without this entry.

Source Code

https://github.com/yushulx/ios-swiftui-barcode-mrz-document-scanner/tree/main/examples/MrzVinScanner