import AVFoundation import SwiftUI import UIKit /// Camera QR scanner, presented as a sheet. /// /// Notes on the shape of this code, since a black preview is easy to ship: /// the preview layer is only created once the camera is authorised, which can /// happen *after* the last layout pass — so its frame is applied at creation /// time as well as in `viewDidLayoutSubviews`, otherwise it stays zero-sized /// and renders as a black rectangle. 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 let sessionQueue = DispatchQueue(label: "com.koins.qr.session") private var previewLayer: AVCaptureVideoPreviewLayer? private var isConfigured = false private var hasScanned = false private let statusLabel = UILabel() private let settingsButton = UIButton(type: .system) private let viewfinder = UIView() 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) viewfinder.layer.borderColor = UIColor.white.withAlphaComponent(0.6).cgColor viewfinder.layer.borderWidth = 2 viewfinder.layer.cornerRadius = 18 viewfinder.isUserInteractionEnabled = false viewfinder.translatesAutoresizingMaskIntoConstraints = false view.addSubview(viewfinder) statusLabel.numberOfLines = 0 statusLabel.textAlignment = .center statusLabel.textColor = .white statusLabel.font = .systemFont(ofSize: 14) statusLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(statusLabel) settingsButton.setTitle("Open Settings", for: .normal) settingsButton.setTitleColor(.systemOrange, for: .normal) settingsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) settingsButton.addTarget(self, action: #selector(openSettings), for: .touchUpInside) settingsButton.isHidden = true settingsButton.translatesAutoresizingMaskIntoConstraints = false view.addSubview(settingsButton) NSLayoutConstraint.activate([ cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), viewfinder.centerXAnchor.constraint(equalTo: view.centerXAnchor), viewfinder.centerYAnchor.constraint(equalTo: view.centerYAnchor), viewfinder.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.68), viewfinder.heightAnchor.constraint(equalTo: viewfinder.widthAnchor), statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), settingsButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 16), settingsButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), ]) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) begin() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Keeps the preview sized even if it was created after the last layout pass. previewLayer?.frame = view.layer.bounds } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } } @objc private func cancelTapped() { onCancel?() } @objc private func openSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } // MARK: - Session private func begin() { guard AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) != nil else { showHelp("This device has no camera. Use “Photo” to read a QR from your library.") return } switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: start() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { guard let self else { return } if granted { self.start() } else { self.showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.") self.settingsButton.isHidden = false } } } default: showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.") settingsButton.isHidden = false } } private func start() { statusLabel.isHidden = true settingsButton.isHidden = true if !isConfigured { configure() } guard isConfigured else { return } sessionQueue.async { [session] in if !session.isRunning { session.startRunning() } } } private func configure() { session.beginConfiguration() session.sessionPreset = .high guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { session.commitConfiguration() showHelp("The camera could not be started. Use “Photo” instead.") return } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { session.commitConfiguration() showHelp("The camera could not be started. Use “Photo” instead.") return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) // Reading a type that is not available raises an Objective-C exception, so // check first (it is only populated once the session has an input). if output.availableMetadataObjectTypes.contains(.qr) { output.metadataObjectTypes = [.qr] } else { session.commitConfiguration() showHelp("This camera cannot read QR codes.") return } session.commitConfiguration() let layer = AVCaptureVideoPreviewLayer(session: session) layer.videoGravity = .resizeAspectFill layer.frame = view.layer.bounds view.layer.insertSublayer(layer, at: 0) previewLayer = layer isConfigured = true view.setNeedsLayout() NotificationCenter.default.addObserver(self, selector: #selector(sessionFailed), name: .AVCaptureSessionRuntimeError, object: session) } @objc private func sessionFailed(_ note: Notification) { showHelp("The camera stopped unexpectedly. Close and try again.") } private func showHelp(_ text: String) { statusLabel.text = text statusLabel.isHidden = false viewfinder.isHidden = true } // MARK: - Detection 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 sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } UINotificationFeedbackGenerator().notificationOccurred(.success) onScan?(value) } }