From 27dc122632f47d42fda0f24b874ad5432c83918a Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 22 Sep 2026 22:45:25 +0800 Subject: [PATCH] Fix the QR scanner showing a black preview --- apps/ios/Koins/Views/QRScannerView.swift | 166 +++++++++++++++++------ 1 file changed, 127 insertions(+), 39 deletions(-) diff --git a/apps/ios/Koins/Views/QRScannerView.swift b/apps/ios/Koins/Views/QRScannerView.swift index 396041e..f645020 100644 --- a/apps/ios/Koins/Views/QRScannerView.swift +++ b/apps/ios/Koins/Views/QRScannerView.swift @@ -1,8 +1,14 @@ import AVFoundation import SwiftUI +import UIKit -/// Camera QR scanner, presented as a sheet. On a simulator (no capture device) -/// it explains that instead of showing a dead viewfinder. +/// 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 @@ -22,9 +28,14 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje 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 var messageLabel: UILabel? + + private let statusLabel = UILabel() + private let settingsButton = UIButton(type: .system) + private let viewfinder = UIView() override func viewDidLoad() { super.viewDidLoad() @@ -37,97 +48,171 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje 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), - ]) - 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 + 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), - requestCamera() + 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() - previewLayer?.frame = view.bounds + // 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) - if session.isRunning { session.stopRunning() } + sessionQueue.async { [session] in + 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.") + @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: - configure(device: device) + start() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { + guard let self else { return } if granted { - self?.configure(device: device) + self.start() } else { - self?.showMessage("Camera access is off. Enable it in Settings, or use “Photo”.") + self.showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.") + self.settingsButton.isHidden = false } } } default: - showMessage("Camera access is off. Enable it in Settings, or use “Photo”.") + showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.") + settingsButton.isHidden = false } } - 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.") + 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 { - showMessage("The camera could not be started. Use “Photo” instead.") + session.commitConfiguration() + showHelp("The camera could not be started. Use “Photo” instead.") return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) - output.metadataObjectTypes = [.qr] + // 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.bounds + layer.frame = view.layer.bounds view.layer.insertSublayer(layer, at: 0) previewLayer = layer + isConfigured = true + view.setNeedsLayout() - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.session.startRunning() - } + NotificationCenter.default.addObserver(self, + selector: #selector(sessionFailed), + name: .AVCaptureSessionRuntimeError, + object: session) } - private func showMessage(_ text: String) { - messageLabel?.text = text + @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) { @@ -135,7 +220,10 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let value = object.stringValue, !value.isEmpty else { return } hasScanned = true - session.stopRunning() + sessionQueue.async { [session] in + if session.isRunning { session.stopRunning() } + } + UINotificationFeedbackGenerator().notificationOccurred(.success) onScan?(value) } } \ No newline at end of file