import AVFoundation import SwiftUI /// Camera QR scanner, presented as a sheet. On a simulator (no capture device) /// it explains that instead of showing a dead viewfinder. struct QRScannerView: UIViewControllerRepresentable { var onScan: (String) -> Void var onCancel: () -> Void func makeUIViewController(context: Context) -> ScannerViewController { let controller = ScannerViewController() controller.onScan = onScan controller.onCancel = onCancel return controller } func updateUIViewController(_ controller: ScannerViewController, context: Context) {} } final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onScan: ((String) -> Void)? var onCancel: (() -> Void)? private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? private var hasScanned = false private var messageLabel: UILabel? override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black let cancel = UIButton(type: .system) cancel.setTitle("Cancel", for: .normal) cancel.setTitleColor(.white, for: .normal) cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) cancel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(cancel) NSLayoutConstraint.activate([ cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), ]) let label = UILabel() label.numberOfLines = 0 label.textAlignment = .center label.textColor = .white label.font = .systemFont(ofSize: 14) label.translatesAutoresizingMaskIntoConstraints = false view.addSubview(label) NSLayoutConstraint.activate([ label.centerYAnchor.constraint(equalTo: view.centerYAnchor), label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), ]) messageLabel = label requestCamera() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.bounds } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if session.isRunning { session.stopRunning() } } @objc private func cancelTapped() { onCancel?() } private func requestCamera() { guard let device = AVCaptureDevice.default(for: .video) else { showMessage("This device has no camera. Use “Photo” to pick a QR from the library instead.") return } switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: configure(device: device) case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { if granted { self?.configure(device: device) } else { self?.showMessage("Camera access is off. Enable it in Settings, or use “Photo”.") } } } default: showMessage("Camera access is off. Enable it in Settings, or use “Photo”.") } } private func configure(device: AVCaptureDevice) { guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { showMessage("The camera could not be started. Use “Photo” instead.") return } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { showMessage("The camera could not be started. Use “Photo” instead.") return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] let layer = AVCaptureVideoPreviewLayer(session: session) layer.videoGravity = .resizeAspectFill layer.frame = view.bounds view.layer.insertSublayer(layer, at: 0) previewLayer = layer DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } } private func showMessage(_ text: String) { messageLabel?.text = text } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasScanned else { return } guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let value = object.stringValue, !value.isEmpty else { return } hasScanned = true session.stopRunning() onScan?(value) } }