QR-Code-Scanner in SwiftUI mit AVFoundation

Das AVFoundation-Framework ermöglicht es mit überschaubarem Aufwand, einen QR-Code-Scanner auf Basis eines eigenen View-Controllers umzusetzen. Im Zuge eines aktuellen Projekts habe ich genau einen solchen benötigt und – das möchte ich an dieser Stelle nicht verschweigen – eine enorm hilfreiche Vorlage bei Hacking with Swift gefunden (siehe Links am Ende des Artikels). Diese Vorlage habe ich lediglich leicht abgewandelt und um einen zusätzlichen Delegate ergänzt (dazu gleich mehr). An dieser Stelle findet ihr bereits einmal den Code des QRCodeScannerViewController:

import AVFoundation
import UIKit

class QRCodeScannerViewController: UIViewController {
    var captureSession: AVCaptureSession!
    
    var previewLayer: AVCaptureVideoPreviewLayer!
    
    var delegate: QRCodeScannerViewControllerDelegate!
    
    override var prefersStatusBarHidden: Bool {
        return true
    }
    
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.black
        captureSession = AVCaptureSession()
        guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return }
        let videoInput: AVCaptureDeviceInput
        do {
            videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
        } catch {
            return
        }
        if (captureSession.canAddInput(videoInput)) {
            captureSession.addInput(videoInput)
        } else {
            captureSession = nil
            return
        }
        let metadataOutput = AVCaptureMetadataOutput()
        if (captureSession.canAddOutput(metadataOutput)) {
            captureSession.addOutput(metadataOutput)
            metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
            metadataOutput.metadataObjectTypes = [.qr]
        } else {
            captureSession = nil
            return
        }
        previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        previewLayer.frame = view.layer.bounds
        previewLayer.videoGravity = .resizeAspectFill
        view.layer.addSublayer(previewLayer)
        captureSession.startRunning()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        if (captureSession?.isRunning == false) {
            captureSession.startRunning()
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        if (captureSession?.isRunning == true) {
            captureSession.stopRunning()
        }
    }
    
    func found(code: String) {
        delegate.qrCodeScannerViewControllerDidScanCode(code)
    }
}

extension QRCodeScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
    func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
        captureSession.stopRunning()
        if let metadataObject = metadataObjects.first {
            guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
            guard let stringValue = readableObject.stringValue else { return }
            AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
            found(code: stringValue)
        }
        dismiss(animated: true)
    }
}

protocol QRCodeScannerViewControllerDelegate {
    func qrCodeScannerViewControllerDidScanCode(_ code: String)
}

Sobald die View lädt beziehungsweise erscheint, baut sie eine Session zur Videoaufzeichnung und Erkennung von QR-Codes auf. Der Delegate-Callback des AVCaptureMetadataOutputObjectsDelegate ist aus Gründen der Übersichtlichkeit in eine separate Extension ausgelagert.

Zusätzlich habe ich einen eigenen Delegate namens QRCodeScannerViewControllerDelegate eingebunden. Über den lässt sich der gescannte Code über eine passende Delegate-Instanz auslesen, die man nach Erstellung des View-Controllers über dessen delegate-Property setzt.

Einbindung in SwiftUI

Dieser View-Controller allein reichte mir aber nicht aus, denn eingangs genanntes Projekt basiert auf SwiftUI. Entsprechend wollte ich den QR-Code-Scanner als View in SwiftUI einbinden.

Zu diesem Zweck erzeugte ich eine neue SwiftUI-View namens QRCodeScannerView, die konform zum UIViewControllerRepresentable-Protokoll ist. Diese View besitzt einen Status namens scannedCode in Form eines Bindings. Sobald ein QR-Code erkannt und erfasst wurde, soll der ermittelte Code in dieses Binding geschrieben werden. So lässt sich an beliebiger Stelle innerhalb des App-Projekts ein QR-Code abfragen.

Um den QR-Code aus dem QRCodeScannerViewController auszulesen, kommt ein Coordinator zum Einsatz, der konform zum von mir selbst implementierten QRCodeScannerViewControllerDelegate-Protokoll ist. In der makeUIViewController(context:)-Methode weise ich der erstellten QRCodeScannerViewController-Instanz jenen Coordinator als Delegate zu.

Feuert der View-Controller nun über seinen Delegate die qrCodeScannerViewControllerDidScanCode(_:)-Methode, gebe ich den so erhaltenen Code über den Coordinator zurück an die SwiftUI-View, genauer gesagt an die scannedCode-Property. So schließt sich der Kreis, und der über den View-Controller gescannte Code steht unter SwiftUI zur Verfügung.

import SwiftUI

struct QRCodeScannerView: UIViewControllerRepresentable {
    @Binding var scannedCode: String?
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> QRCodeScannerViewController {
        let qrCodeScannerViewController = QRCodeScannerViewController()
        qrCodeScannerViewController.delegate = context.coordinator
        return qrCodeScannerViewController
    }
    
    func updateUIViewController(_ uiViewController: QRCodeScannerViewController, context: Context) {}
    
    class Coordinator: NSObject, QRCodeScannerViewControllerDelegate {
        var parent: QRCodeScannerView
        
        init(_ qrCodeScannerView: QRCodeScannerView) {
            parent = qrCodeScannerView
        }
        
        func qrCodeScannerViewControllerDidScanCode(_ code: String) {
            parent.scannedCode = code
        }
    }
}

Einsatz in SwiftUI

Die QRCodeScannerView könnt ihr nun nach Belieben einsetzen, um QR-Codes via SwiftUI zu erfassen. Bedenkt aber, in der Info.plist-Datei eures Projekts den Key Privacy – Camera Usage Description zu ergänzen. Über den müsst ihr in Form eines Strings festlegen, warum ihr mit eurer App auf die Kamera zugreifen wollt. Fehlt der Schlüssel, kommt es beim Erstellen der QRCodeScannerView zum Crash.

Ich selbst nutzte einen eigens kreierten ScanButton, um die QRCodeScannerView in Form eines Sheets einzublenden. Über diesen Button ermittle ich bei dessen Betätigung auch, ob die App die Berechtigung zum Zugriff auf die Kamera besitzt. Falls nicht, blende ich einen Alert ein, der auf dieses Problem hinweist und den Nutzer auf Wunsch direkt in die Einstellungen führt, wo er die Berechtigungen ändern kann.

struct ScanButton: View {
    @State private var showsScanner = false
    
    @State private var showsMissingCameraAccessAlert = false
    
    @Binding var scannedCode: String?
    
    var body: some View {
        Button(action: {
            AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) -> Void in
                if granted {
                    showsScanner = true
                } else {
                    showsMissingCameraAccessAlert = true
                }
            })
        }, label: {
            VStack {
                Image(systemName: "qrcode.viewfinder")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(height: 80)
                Text("Scannen")
                    .font(.title)
            }
        })
        .alert("Die App bentötigt für das Scannen des QR-Codes Zugriff auf die Kamera", isPresented: $showsMissingCameraAccessAlert, actions: {
            openSettingsButton
            dismissMissingCameraAccessAlertButton
        }, message: {
            Text("Bitte erlauben Sie den Zugriff auf die Kamera in den Einstellungen.")
        })
        .sheet(isPresented: $showsScanner) {
            QRCodeScannerView(scannedCode: $scannedCode)
        }
    }
    
    private var openSettingsButton: some View {
        Button("Einstellungen öffnen") {
            showsMissingCameraAccessAlert = false
            UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
        }
    }
    
    private var dismissMissingCameraAccessAlertButton: some View {
        Button("Abbrechen", role: .cancel) {
            showsMissingCameraAccessAlert = false
        }
    }
}

Fazit

Ich war überrascht, wie unkompliziert sich ein QR-Code-Scanner in SwiftUI integrieren ließ. Hier zeigt sich wieder einmal ganz deutlich, wie mächtig die verschiedenen Representable-Protokolle des SwiftUI-Frameworks sind. Der Coordinator kümmert sich um die essenziellen Aktionen zum Verarbeiten der Daten und gibt diese an ein Binding weiter. Wo genau der Status dieses Bindings gespeichert ist, ist irrelevant, wodurch sich die QRCodeScannerView an beliebigen Stellen einsetzen lässt.

Euer Thomas

Weiterführende Links zum Artikel


Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert