Fix the QR scanner showing a black preview
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user