Using the Scanner from SwiftUI
The SDK ships its scanner as a UIViewController, so a SwiftUI app reaches it through a UIViewControllerRepresentable bridge. That bridge is the only SDK-facing code a SwiftUI integration needs; everything above it is ordinary SwiftUI.
Two samples take this route. ScanMRZBasicSwiftUI shows the whole pattern on one screen and is what this page builds. ScanMRZSwiftUI adds a dedicated result screen on top of it, covered under Navigating to a separate result screen.
Full source on GitHub: ScanMRZBasicSwiftUI and ScanMRZSwiftUI.
Before you start
This page replaces Steps 4 through 6 of the MRZ Scanner User Guide. Its first three steps still apply, with one change:
- Create the project with Interface set to SwiftUI rather than Storyboard. Leave the generated
Appstruct alone — a SwiftUI app has noSceneDelegate, so there is no equivalent of the guide’s Step 4 and nothing to delete. - Add the SDK exactly as the guide describes.
- Declare the camera usage description — still mandatory, and still crashes the app if missing.
SwiftUI requires Swift. The Objective-C tabs in the user guide have no counterpart on this page.
The SwiftUI app lifecycle used by these samples requires iOS 16 or later, which is also the SDK’s own minimum.
The bridge
Add a new Swift file named MRZScannerView.swift. The bridge is the same in both SwiftUI samples; only the config differs, and ScanMRZSwiftUI adds the commented-out catalog to its copy:
import SwiftUI
import DynamsoftMRZScannerBundle
struct MRZScannerView: UIViewControllerRepresentable {
// Called once the scanner finishes, is canceled, or fails.
let onScannedResult: (MRZScanResult) -> Void
func makeUIViewController(context: Context) -> MRZScannerViewController {
let config = MRZScannerConfig()
// A trial license, so it needs a network connection. Request your own at
// https://www.dynamsoft.com/customer/license/trialLicense?product=mrz&utm_source=guide&package=ios
config.license = "DLS2eyJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSJ9"
let scanner = MRZScannerViewController()
scanner.config = config
scanner.onScannedResult = onScannedResult
return scanner
}
func updateUIViewController(_ uiViewController: MRZScannerViewController,
context: Context) {
// The scanner is configured once in makeUIViewController and owns its own
// state from then on, so there is nothing to push down on state changes.
}
}
updateUIViewController is deliberately empty. SwiftUI calls it whenever surrounding state changes, but the scanner reads its config when it starts and manages its own capture session after that, so there is nothing to re-apply. Reassigning config here would have no effect on a scan already in progress.
Any configuration belongs in makeUIViewController alongside the license — document type, UI element visibility, which images to return.
Presenting the scanner
Present the bridge from a .fullScreenCover. That is the SwiftUI equivalent of the modal full-screen presentation the user guide uses: the scanner draws its own close button and expects the whole screen, so .ignoresSafeArea() keeps the camera preview edge to edge.
import SwiftUI
import DynamsoftMRZScannerBundle
import DynamsoftCaptureVisionBundle
// Everything the result section shows, captured off the scan result.
private struct ScannedDocument {
let data: MRZData
let portrait: UIImage?
}
struct ContentView: View {
@State private var isScanning = false
// Canceled message or error string, shown in place of the fields.
@State private var status = ""
@State private var scanned: ScannedDocument?
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if !status.isEmpty {
Text(status)
}
if let scanned = scanned {
results(for: scanned)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
Button {
isScanning = true
} label: {
Text("Scan an MRZ")
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Color.accentColor)
.cornerRadius(8)
}
.padding()
}
.fullScreenCover(isPresented: $isScanning) {
MRZScannerView(onScannedResult: handle(result:))
.ignoresSafeArea()
}
}
}
Handling the result
onScannedResult reports all three outcomes through the same closure. The SwiftUI version reads a little differently from the UIKit one:
// Renders one of the three result statuses the scanner can come back with.
// onScannedResult arrives off the main thread, so every state change hops first.
private func handle(result: MRZScanResult) {
switch result.resultStatus {
case .finished:
guard let data = result.data else { return }
let document = ScannedDocument(
data: data,
portrait: try? result.getPortraitImage()?.toUIImage()
)
DispatchQueue.main.async {
status = ""
scanned = document
isScanning = false
}
case .canceled:
// The user closed the scanner. There is no data and nothing went wrong.
DispatchQueue.main.async {
status = "Scan canceled"
scanned = nil
isScanning = false
}
case .exception:
// The scanner asks for camera access itself, so a denial lands here as a
// readable error string. This app needs no permission code of its own.
let errorString = result.errorString ?? ""
DispatchQueue.main.async {
status = errorString
scanned = nil
isScanning = false
}
@unknown default:
break
}
}
Three things differ from the UIKit flow:
- Dismissal is a state change. Setting
isScanning = falsecloses the cover, in place of adismiss(animated:)call. The scanner still does not close itself — something has to do it. - Every branch hops to the main queue.
onScannedResultarrives off the main thread, and mutating@Statefrom a background thread is a bug even when it appears to work. - Values are read before the hop. The portrait conversion and
result.errorStringhappen outsideDispatchQueue.main.async, so each closure captures a plain value instead of reaching back into the result.
The results(for:) and field-rendering helpers hold the same per-field validation logic the user guide covers in Reading a field’s validation status, expressed as SwiftUI views: a caption, the value, and an amber tint when getFieldValidationStatus returns .failed.
Navigating to a separate result screen
If the result goes to its own screen rather than the same one, carry the scanned data inside the navigation route rather than in a companion @State property:
struct ScanPayload: Hashable, Identifiable {
let id = UUID()
let data: MRZData
let portraitImage: UIImage?
let primaryDocumentImage: UIImage?
let primaryOriginalImage: UIImage?
let secondaryDocumentImage: UIImage?
let secondaryOriginalImage: UIImage?
// Identity is the payload's own id — hashing the images themselves would be
// both expensive and meaningless for navigation.
static func == (lhs: ScanPayload, rhs: ScanPayload) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
enum Route: Hashable {
case scanner
case result(ScanPayload)
}
Keeping the payload alongside the path avoids a race: NavigationStack can evaluate the destination before a separately-stored @State value lands, which renders an empty result screen on the first push. Appending .result(payload) to the path makes the data arrive with the navigation rather than after it.
The custom Hashable conformance matters for a second reason. Route has to be hashable for NavigationStack, and the payload carries up to five UIImage values — hashing those would be expensive and tells you nothing useful, so identity is derived from the UUID instead.
ScanMRZSwiftUI pushes the scanner as a route too, hiding the navigation bar while it is on screen:
.navigationDestination(for: Route.self) { route in
switch route {
case .scanner:
MRZScannerView(onScannedResult: handle(result:))
.ignoresSafeArea()
.toolbar(.hidden, for: .navigationBar)
.navigationBarBackButtonHidden(true)
case .result(let payload):
ResultView(payload: payload,
onRescan: { if !path.isEmpty { path.removeLast() } },
onReturnHome: { path.removeAll() })
}
}
Hiding the bar and the back button leaves the scanner’s own close button as the single way out, which keeps cancellation flowing through onScannedResult rather than through a navigation gesture the scanner never learns about.
Next steps
- MRZ Scanner User Guide — the UIKit walkthrough, and the shared project setup steps.
- ScanMRZ Demo App — the complete UIKit sample and its result screen.
- Customizing the MRZ Scanner — document type, UI elements, feedback, and camera permission.
- iOS API Reference — all classes and methods.