Skip to main content

MRZ Detection

RequiresMRZ entitlement

MRZ detection fires as soon as the SDK sees something that looks like an MRZ, before it has finished reading it. This quickstart uses that signal to keep the screen clean until a document appears, then shows the camera preview with guides, dims the kiosk LEDs to cut glare, and displays the parsed result when the full read completes. If nothing happens for five seconds the preview hides again.

Folder in the download: MRZDetection/. Scheme: MRZDetection.

What you'll see​

MRZ Detection app showing the camera preview appearing after a document is detected
Idle text until a document is detected, then the preview appears and the result fills in.

How it works​

Turning detection on. config.mrzDetectionMode = .on alongside config.mrzMode = .on. Detection is separate from reading: you can have one without the other.

Listening for the notification. The SDK posts AilaMRZDetectedNotification through NotificationCenter each time it detects an MRZ. The view model defines a Notification.Name for it and observes it on the main queue. The view also observes it with onReceive so the UI reacts immediately. Both paths call markDetection().

Showing the preview on demand. shouldShowOverlay is true while lastDetectionTime is set. The view keeps AilaCaptureViewRepresentable in the hierarchy at all times but sets its opacity to zero when idle, then calls reconnectSession() and displayMRZGuides(true) through the coordinator when it becomes visible. markDetection() schedules a task that clears the state after five seconds without another detection.

Dimming kiosk LEDs (optional). This part only applies when the device is docked in an Interactive Kiosk. There, Aila_OverrideLightLevels(&levels) with AilaLightLevels(area: 0.0, spot: 0.0) turns the illumination off, which reduces glare on glossy document laminates. Passing nil restores the SDK's own lighting control. The quickstart overrides on detection and restores after two seconds, or when the result is cleared, or when scanning stops. On a device without Aila hardware these calls are harmless no-ops.

Handling the full read. The scanCallback path is the same as MRZ Scanning. When a .typeMRZ result arrives it is parsed into MRZDetectionResult and markDetection() is called again to keep the preview up while the user reads the result.

MRZDetection/ViewModel.swift
import Foundation
import Aila

extension Notification.Name {
static let ailaMRZDetected = Notification.Name("AilaMRZDetectedNotification")
}

struct MRZDetectionResult: Identifiable {
let id = UUID()
let rawData: String?
let parsedFields: [(String, String)]
let jsonString: String

static func from(data: String, rawData: String?) -> MRZDetectionResult {
var fields: [(String, String)] = []
if let utf8 = data.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: utf8) as? [String: Any] {
let keys = [
"Surname", "Given Names", "Sex", "Birth Date", "Expiration Date",
"Nationality", "Country Code", "Document Type", "Document Number",
"Optional Data", "Optional Data 2"
]
for key in keys {
if let value = dict[key] as? String, !value.isEmpty {
fields.append((key, value))
}
}
}
let effectiveRaw: String? = (rawData?.isEmpty == false) ? rawData : nil
return MRZDetectionResult(rawData: effectiveRaw, parsedFields: fields, jsonString: data)
}
}

final class MRZDetectionViewModel: ObservableObject {
@Published private(set) var lastDetectionTime: Date?
@Published private(set) var fullMRZResult: MRZDetectionResult?

private var notificationObserver: NSObjectProtocol?
private var dismissalTask: Task<Void, Never>?
private var lightingRestoreTask: Task<Void, Never>?
private var isLightingOverridden: Bool = false

func startScanning() {
Aila_Init()
Aila_SetConfiguration(buildConfiguration())
Aila_Start()
addNotificationObserver()
}

func stopScanning() {
dismissalTask?.cancel()
dismissalTask = nil
lightingRestoreTask?.cancel()
lightingRestoreTask = nil
restoreLighting()
removeNotificationObserver()
Aila_Stop()
}

func markDetection() {
lastDetectionTime = Date()
dismissalTask?.cancel()

// Helpful to override the LED lighting on kiosks to prevent glare and get better MRZ reads
triggerDetectionLighting()

// after 5 seconds the overlay will dismiss without further mrz detections
dismissalTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000)
if !Task.isCancelled {
lastDetectionTime = nil
fullMRZResult = nil
restoreLighting()
}
}
}

func clearResult() {
fullMRZResult = nil
restoreLighting()
}

var shouldShowOverlay: Bool {
lastDetectionTime != nil
}

private func buildConfiguration() -> AilaConfiguration {
let config = AilaConfiguration()
config.mrzMode = .on
config.mrzDetectionMode = .on
config.beepVolume = .volume3

config.setCode(.typeUPC, enabled: true)
config.setCode(.typeEAN, enabled: true)
config.setCode(.typeQR, enabled: true)
config.setCode(.type128, enabled: true)
config.setCode(.typePDF417, enabled: true)

config.scanCallback = { [weak self] results in
self?.handleScanResults(results)
}

return config
}

private func handleScanResults(_ results: [AilaScanObject]?) {
guard let results = results, !results.isEmpty else { return }

for obj in results {
guard obj.type == .typeMRZ, let data = obj.data else { continue }

let rawData: String? = (obj as? AilaMRZScanObject)?.rawData
let mrzResult = MRZDetectionResult.from(data: data, rawData: rawData)
DispatchQueue.main.async { [weak self] in
self?.fullMRZResult = mrzResult
self?.markDetection()
}
break
}
}

private func addNotificationObserver() {
removeNotificationObserver()
notificationObserver = NotificationCenter.default.addObserver(
forName: .ailaMRZDetected,
object: nil,
queue: .main
) { [weak self] _ in
self?.markDetection()
}
}

private func removeNotificationObserver() {
if let observer = notificationObserver {
NotificationCenter.default.removeObserver(observer)
notificationObserver = nil
}
}

// MARK: - LED Lighting Control

private func triggerDetectionLighting() {
guard !isLightingOverridden else { return }
var lightLevels = AilaLightLevels(area: 0.0, spot: 0.0)
Aila_OverrideLightLevels(&lightLevels)
isLightingOverridden = true
// after 2 seconds go back to what LED lighting was before
lightingRestoreTask?.cancel()
lightingRestoreTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
if !Task.isCancelled {
restoreLighting()
}
}
}

private func restoreLighting() {
guard isLightingOverridden else { return }
Aila_OverrideLightLevels(nil)
isLightingOverridden = false
lightingRestoreTask?.cancel()
lightingRestoreTask = nil
}
}

The wrapper is the same coordinator pattern as MRZ with Guides, with guides always on and reconnectAVCaptureSession() called when the view is created.

MRZDetection/AilaCaptureViewRepresentable.swift
MRZDetection/AilaCaptureViewRepresentable.swift
//
// Quickstart: MRZ Detection – AilaCaptureView with MRZ guides (same pattern as MRZ With Guides).
//

import SwiftUI
import AVFoundation
import Aila

struct AilaCaptureViewRepresentable: UIViewRepresentable {
@Binding var externalCoordinator: Coordinator?

func makeCoordinator() -> Coordinator {
let coordinator = Coordinator()
DispatchQueue.main.async { self.externalCoordinator = coordinator }
return coordinator
}

func makeUIView(context: Context) -> AilaCaptureView {
let view = AilaCaptureView()
context.coordinator.captureView = view
view.reconnectAVCaptureSession()
view.displayMRZGuides(true)
return view
}

func updateUIView(_ uiView: AilaCaptureView, context: Context) {
context.coordinator.captureView = uiView
}

class Coordinator {
fileprivate var captureView: AilaCaptureView?

func reconnectSession() {
captureView?.reconnectAVCaptureSession()
}

func displayMRZGuides(_ shouldShow: Bool) {
captureView?.displayMRZGuides(shouldShow)
}
}
}
MRZDetection/View.swift
MRZDetection/View.swift
//
// Quickstart: MRZ Detection – View
// No preview until MRZ is detected; then camera + full MRZ scan on same screen.
//

import Foundation
import SwiftUI

struct MRZDetectionView: View {
@StateObject private var viewModel = MRZDetectionViewModel()
@State private var captureCoordinator: AilaCaptureViewRepresentable.Coordinator?

var body: some View {
NavigationStack {
VStack(spacing: 16) {
AilaCaptureViewRepresentable(
externalCoordinator: $captureCoordinator
)
.cornerRadius(16)
.padding()
.shadow(radius: 4)
.opacity(viewModel.shouldShowOverlay ? 1 : 0)

if let result = viewModel.fullMRZResult {
mrzResultView(result)
} else if viewModel.shouldShowOverlay {
placeholderView
} else {
idleView
}
}
.padding(.bottom)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.navigationTitle("MRZ Detection")
.toolbar {
if viewModel.fullMRZResult != nil {
ToolbarItem(placement: .cancellationAction) {
Button("Clear") {
viewModel.clearResult()
}
}
}
}
.onChange(of: viewModel.shouldShowOverlay) { showing in
if showing {
DispatchQueue.main.async {
captureCoordinator?.reconnectSession()
captureCoordinator?.displayMRZGuides(true)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .ailaMRZDetected)) { _ in
viewModel.markDetection()
}
.onAppear {
viewModel.startScanning()
}
.onDisappear {
viewModel.stopScanning()
}
}
}

private func mrzResultView(_ result: MRZDetectionResult) -> some View {
List {
Section("Parsed fields") {
ForEach(Array(result.parsedFields.enumerated()), id: \.offset) { _, pair in
HStack(alignment: .top) {
Text(pair.0)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 12)
Text(pair.1)
.font(.body)
.multilineTextAlignment(.trailing)
}
}
if result.parsedFields.isEmpty {
Text("No fields parsed (or unlicensed). Raw data below.")
.foregroundStyle(.secondary)
.font(.subheadline)
}
}
if let raw = result.rawData, !raw.isEmpty {
Section("Raw MRZ") {
Text(raw)
.font(.system(.caption, design: .monospaced))
}
}
Section("JSON") {
Text(result.jsonString)
.font(.system(.caption, design: .monospaced))
.lineLimit(20)
}
}
.listStyle(.insetGrouped)
}

private var placeholderView: some View {
VStack(spacing: 12) {
Text("MRZ detected")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Align document to scan full MRZ. Results appear below.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}

private var idleView: some View {
VStack(spacing: 12) {
Text("Point camera at MRZ")
.font(.subheadline)
Text("Camera will appear when MRZ is detected.")
.font(.caption)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

Try changing​

  • Lengthen the five-second dismissal or remove it so the preview stays until the user taps Clear.
  • Use detection to play a sound or vibrate with Aila_NotifyVibrate instead of showing the camera.

API used​

Next: parse a US driver's license with Driver's License Parsing.


Want the code? The full Xcode project is available as a zip on the Quickstarts overview.