Table of contents

Building the ScanMRZ Demo App

The MRZ Scanner User Guide builds ScanMRZBasic, the smallest app that scans an MRZ and shows the parsed data on a single screen. This page builds ScanMRZ, the complete demo app on top of it.

The MRZ Scanner Demo published on the App Store is this same implementation behind a more polished landing screen. Building ScanMRZ gives you the full scanning and result experience; the store app adds branding around it.

Everything on this page is presentation. It uses the same SDK calls the user guide covers — MRZScannerConfig, MRZScannerViewController, onScannedResult, and the getters on MRZScanResult — and adds a result screen around them. None of it is required in order to use the MRZ Scanner.

Full source on GitHub: ScanMRZ. This page covers the UIKit sample; its SwiftUI twin is covered in Using the Scanner from SwiftUI. The code below is Swift, as the sample is — there is no Objective-C version of this app.

What ScanMRZ adds

  ScanMRZBasic ScanMRZ
Screens one view controller ViewController plus a dedicated ResultViewController
Navigation scanner presented modally UINavigationController, scanner and result pushed
Result data seven fields, plain text full field set, grouped into personal and document sections
Images portrait only portrait, plus both document sides, with a Processed/Original switcher when originals are returned
Failed validation value colored amber amber, underlined, with an inline icon and an explanatory dialog
Permission denial error string on screen error string plus an Open Settings button
Extras — long-press the portrait or a document image to save it to Photos

Project structure

Neither app has a storyboard — both build their root view controller in SceneDelegate. The one structural difference is that ScanMRZ wraps its root in a UINavigationController:

guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
let rootViewController = ViewController()
let navigationController = UINavigationController(rootViewController: rootViewController)
window.rootViewController = navigationController
self.window = window
window.makeKeyAndVisible()

That navigation controller is what makes the rest possible. The scanner and the result screen are pushed rather than presented, so Re-scan can pop one screen and Return home can pop to the root.

The navigation bar is toggled per screen: ViewController hides it in viewWillAppear and ResultViewController shows it there, so it appears only where there is something to title. viewWillAppear rather than viewDidLoad, because the home screen has to hide the bar again every time the result screen is popped off it.

ViewController

Pushing the scanner

Only the license is required, exactly as in the user guide. ScanMRZ doubles as a catalog of the rest: every remaining MRZScannerConfig setting appears commented out and assigned the opposite of its default, so uncommenting a single line is enough to see what that setting changes.

private typealias ScannedImages = (portrait: UIImage?, primaryDoc: UIImage?,
                                   primaryOrig: UIImage?, secondaryDoc: UIImage?,
                                   secondaryOrig: UIImage?)

@objc func buttonTapped() {
    let config = MRZScannerConfig()
    config.license = "DLS2eyJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSJ9"

    //config.documentType = .passport         // Default is .all, which reads both.
    //config.isTorchButtonVisible = false     // Every scanner control is visible by default.
    //config.isBeepEnabled = true             // Scan feedback is off by default.
    //config.returnOriginalImage = true       // The full camera frame is not returned by default.

    let vc = MRZScannerViewController()
    vc.config = config
    vc.onScannedResult = { [weak self] result in
        let images: ScannedImages = (
            portrait:      try? result.getPortraitImage()?.toUIImage(),
            primaryDoc:    try? result.getDocumentImage(.mrz)?.toUIImage(),
            primaryOrig:   try? result.getOriginalImage(.mrz)?.toUIImage(),
            secondaryDoc:  try? result.getDocumentImage(.opposite)?.toUIImage(),
            secondaryOrig: try? result.getOriginalImage(.opposite)?.toUIImage()
        )
        DispatchQueue.main.async { self?.handle(result, images) }
    }

    label.isHidden = true
    settingsButton.isHidden = true
    navigationController?.pushViewController(vc, animated: true)
}

Customizing the MRZ Scanner covers what each setting does.

onScannedResult arrives off the main thread, so the callback decodes the images there — with returnOriginalImage on they are full camera frames, and converting those on the main thread stalls the UI — then hops once, which leaves one place where all three statuses are handled:

private func handle(_ result: MRZScanResult, _ images: ScannedImages) {
    switch result.resultStatus {
    case .finished:
        guard let data = result.data else {
            report("Scan returned no data")
            return
        }
        let resultVC = ResultViewController()
        resultVC.mrzData = data
        resultVC.portraitImage = images.portrait
        resultVC.primaryDocumentImage = images.primaryDoc
        resultVC.primaryOriginalImage = images.primaryOrig
        resultVC.secondaryDocumentImage = images.secondaryDoc
        resultVC.secondaryOriginalImage = images.secondaryOrig
        navigationController?.pushViewController(resultVC, animated: true)
    case .canceled:
        report("Scan canceled")
    case .exception:
        report(result.errorString ?? "")
        settingsButton.isHidden =
            result.errorCode != ErrorCode.cameraPermissionDenied.rawValue
    @unknown default:
        break
    }
}

private func report(_ message: String) {
    label.text = message
    label.isHidden = false
    navigationController?.popViewController(animated: true)
}

All five images reach the result screen as plain UIImage properties, so ResultViewController never holds the MRZScanResult. Three of the five are normally nil — getOriginalImage(_:) returns nothing unless returnOriginalImage is set, and both .opposite getters return nothing for a passport. The result screen collapses whatever is missing rather than reserving space for it.

A .finished result with no data reports rather than returning silently: the scanner was pushed, so an early return would leave the user staring at it with no feedback.

Because the scanner was pushed, the cancel and error branches both pop it, which is what report does after leaving its message behind. The success branch does not: it pushes the result screen on top, and the scanner is removed from the stack when Re-scan pops back to it.

Recovering from a permission denial

This is the part with no counterpart in ScanMRZBasic, and the reason it exists is specific to iOS. The scanner shows its own alert offering Open Settings, but changing a privacy setting there terminates the app. By the time the user returns, the scanner and its alert are long gone — so the home screen keeps its own route into Settings:

private let settingsButton = ViewController.makeStyledButton(title: "Open Settings", fontSize: 16)

@objc func openSettingsTapped() {
    guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
    UIApplication.shared.open(url)
}

The .exception branch above shows the button only for cameraPermissionDenied. A camera withheld by device policy reports cameraPermissionRestricted instead, and in that state the per-app camera toggle is absent from Settings — so offering the button would be a dead end. The error string still explains the situation.

Suppressing the scanner’s own alert entirely is one of the settings in the config catalog: isCameraPermissionPromptEnabled = false leaves the denial to onScannedResult alone, which is where the button above already comes from.

ResultViewController

What it receives

Six plain properties, set before the push:

var mrzData: MRZData?
var portraitImage: UIImage?
var primaryDocumentImage: UIImage?
var primaryOriginalImage: UIImage?
var secondaryDocumentImage: UIImage?
var secondaryOriginalImage: UIImage?

Because these are UIImage values rather than ImageData, the result screen has no dependency on the SDK for its images. It imports DynamsoftMRZScannerBundle only for MRZData, and DynamsoftCaptureVisionBundle only for ValidationStatus.

The Processed / Original switcher

The two document sides are shown side by side. When both the cropped (“Processed”) and full-frame (“Original”) versions came back, a custom segmented control chooses between them. It is built from two buttons with underline views rather than a UISegmentedControl, so the selected tab can be underlined:

@objc private func processedTapped() {
    isProcessedSelected = true
    updateSegmentAppearance()
    updateDocumentImages()
}

The interesting part is what happens when a set is missing. With default settings returnOriginalImage is false, so there is no Original set at all — and a tab bar with a single tab is a control with nothing to switch between. In that case the switcher is replaced by a plain header naming what is on screen, styled like the section headers below it:

private func updateImageSectionVisibility() {
    let hasProcessed = primaryDocumentImage != nil || secondaryDocumentImage != nil
    let hasOriginal = primaryOriginalImage != nil || secondaryOriginalImage != nil
    let hasAnyImage = hasProcessed || hasOriginal

    let showsTabs = hasProcessed && hasOriginal
    if !showsTabs {
        isProcessedSelected = hasProcessed
    }
    updateSegmentAppearance()

    segmentContainerView.isHidden = !showsTabs
    imagesHeaderLabel.isHidden = showsTabs || !hasAnyImage
    imagesHeaderLabel.text = hasProcessed ? "Processed Image(s)" : "Original Image(s)"

    segmentTopConstraint.constant = hasAnyImage ? 24 : 0
    segmentHeightConstraint.constant = hasAnyImage ? 30 : 0

    imageStackView.isHidden = !hasAnyImage
    imageStackHeightConstraint.constant = hasAnyImage ? 160 : 0
    imageStackTopConstraint.constant = hasAnyImage ? 16 : 0
}

Note that hiding is not enough. A hidden view still occupies whatever space its constraints reserve, so the heights and the gaps above them are zeroed as well — which is why those four constraints are held as properties. The header label shares the switcher’s slot, so the images below stay anchored to the same place whichever one is showing.

Showing a failed field

The user guide colors a failed value amber. ScanMRZ goes further: it underlines the value so it reads as tappable, appends an inline amber icon, and opens a dialog explaining what a failed check digit means.

private static let warningAmber = UIColor(red: 1.0, green: 193.0/255.0, blue: 7.0/255.0, alpha: 1.0)

private static func applyFailedValue(_ text: String, to label: UILabel) {
    let font: UIFont = label.font
    let result = NSMutableAttributedString(string: text, attributes: [
        .underlineStyle: NSUnderlineStyle.single.rawValue,
        .foregroundColor: warningAmber,
        .font: font
    ])

    let iconHeight = font.pointSize * 1.2
    let attachment = NSTextAttachment()
    attachment.image = UIImage(
        systemName: "exclamationmark.circle.fill",
        withConfiguration: UIImage.SymbolConfiguration(pointSize: iconHeight)
    )?.withTintColor(warningAmber, renderingMode: .alwaysOriginal)
    if let icon = attachment.image {
        attachment.bounds = CGRect(x: 0, y: font.descender,
                                   width: iconHeight * icon.size.width / icon.size.height,
                                   height: iconHeight)
    }

    result.append(NSAttributedString(string: "  "))
    result.append(NSAttributedString(attachment: attachment))

    label.attributedText = result
    label.accessibilityLabel = "\(text), validation failed"
}

Two details worth copying. The icon is a NSTextAttachment sized from the label’s own font, so it scales with the text instead of being a fixed-size image. And the accessibilityLabel states the failure in words — an amber tint and an icon are both invisible to VoiceOver, so without it the failure simply would not be announced.

Tapping a failed row opens the explanation:

@objc private func showValidationInfoDialog() {
    let alert = UIAlertController(
        title: "Field validation warning",
        message: "This value doesn't match its check digit. The document may be invalid or altered.",
        preferredStyle: .alert
    )
    alert.addAction(UIAlertAction(title: "OK", style: .default))
    present(alert, animated: true)
}

The gesture recognizer is attached only when the status is .failed, so rows that passed are not interactive:

if failed {
    let tap = UITapGestureRecognizer(target: self, action: #selector(showValidationInfoDialog))
    containerView.addGestureRecognizer(tap)
    containerView.isUserInteractionEnabled = true
}

Grouping the fields

populateData splits the fields into a summary header, a Personal Info section, and a Document Info section:

personalInfoStackView.addArrangedSubview(createInfoRow(label: "Given Name",    value: data.firstName,      status: data.getFieldValidationStatus("firstName")))
personalInfoStackView.addArrangedSubview(createInfoRow(label: "Surname",       value: data.lastName,       status: data.getFieldValidationStatus("lastName")))
personalInfoStackView.addArrangedSubview(createInfoRow(label: "Date of Birth", value: data.dateOfBirth,    status: data.getFieldValidationStatus("dateOfBirth")))
personalInfoStackView.addArrangedSubview(createInfoRow(label: "Gender",        value: genderText,          status: data.getFieldValidationStatus("sex")))
personalInfoStackView.addArrangedSubview(createInfoRow(label: "Nationality",   value: data.nationalityRaw, status: data.getFieldValidationStatus("nationality")))

Three decisions in that block are worth noting:

  • The summary header carries no validation highlighting. nameLabel and subInfoLabel join several fields into one line each — “gender, age” — and tinting a compound line on one field’s status would imply both are invalid. Field-level validation is surfaced in the sections below instead.
  • Doc. Type is passed no status. It is derived from the MRZ layout rather than read from a field with a check digit, so it has nothing to validate against. The sample maps it to friendly text: data.documentType == "MRTD_TD3_PASSPORT" ? "Passport" : "ID".
  • Nationality uses nationalityRaw. That is the three-letter ICAO code as it appears in the MRZ, rather than the expanded country name.

The raw MRZ text is tappable too, for a reason that is easy to miss: a line-composite failure can flag the raw MRZ when no individual field failed — corruption in a field that has no check digit of its own, such as name, nationality or sex.

applyLabel(mrzValueLabel,
           text: data.mrzText,
           status: data.getFieldValidationStatus("mrzText"),
           defaultColor: .lightGray)

mrzText aggregates whole MRZ lines, so it can report .failed when every individual field passes — see getFieldValidationStatus.

Re-scan and Return home

Both actions are navigation, which is the payoff for pushing rather than presenting:

@objc private func rescanTapped() {
    navigationController?.popViewController(animated: true)
}

@objc private func returnHomeTapped() {
    navigationController?.popToRootViewController(animated: true)
}

Re-scan pops back to the scanner, which is still on the stack and resets its own state in viewWillAppear. Return home pops all the way to ViewController.

Saving an image to Photos

A long press on the portrait or either document image offers to save it:

@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
    guard gesture.state == .began,
          let imageView = gesture.view as? UIImageView,
          let image = imageView.image else { return }

    let alert = UIAlertController(title: "Save Image",
                                  message: "Would you like to save this image to your photos?",
                                  preferredStyle: .actionSheet)
    alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in
        UIImageWriteToSavedPhotosAlbum(image, self,
            #selector(self.image(_:didFinishSavingWithError:contextInfo:)), nil)
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

    if let popoverController = alert.popoverPresentationController {
        popoverController.sourceView = imageView
        popoverController.sourceRect = imageView.bounds
    }

    present(alert, animated: true)
}

Writing to the photo library needs its own usage description. ScanMRZ declares Privacy - Photo Library Additions Usage Description (NSPhotoLibraryAddUsageDescription) alongside the camera one; without it this action crashes the app the same way a missing camera description does.

Setting popoverPresentationController.sourceView is not optional either — an action sheet with no source view raises an exception on iPad.

Next steps

This page is compatible for: