Fix the QR scanner showing a black preview

This commit is contained in:
2026-09-22 22:45:25 +08:00
parent e9720391b7
commit 27dc122632
+125 -37
View File
@@ -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)
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)
// 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)
}
}