Fix the QR scanner showing a black preview
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Camera QR scanner, presented as a sheet. On a simulator (no capture device)
|
/// Camera QR scanner, presented as a sheet.
|
||||||
/// it explains that instead of showing a dead viewfinder.
|
///
|
||||||
|
/// 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 {
|
struct QRScannerView: UIViewControllerRepresentable {
|
||||||
var onScan: (String) -> Void
|
var onScan: (String) -> Void
|
||||||
var onCancel: () -> Void
|
var onCancel: () -> Void
|
||||||
@@ -22,9 +28,14 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
var onCancel: (() -> Void)?
|
var onCancel: (() -> Void)?
|
||||||
|
|
||||||
private let session = AVCaptureSession()
|
private let session = AVCaptureSession()
|
||||||
|
private let sessionQueue = DispatchQueue(label: "com.koins.qr.session")
|
||||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var isConfigured = false
|
||||||
private var hasScanned = 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() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
@@ -37,97 +48,171 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
cancel.translatesAutoresizingMaskIntoConstraints = false
|
cancel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(cancel)
|
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([
|
NSLayoutConstraint.activate([
|
||||||
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||||
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
])
|
|
||||||
|
|
||||||
let label = UILabel()
|
viewfinder.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
label.numberOfLines = 0
|
viewfinder.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
label.textAlignment = .center
|
viewfinder.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.68),
|
||||||
label.textColor = .white
|
viewfinder.heightAnchor.constraint(equalTo: viewfinder.widthAnchor),
|
||||||
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()
|
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() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.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) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
sessionQueue.async { [session] in
|
||||||
if session.isRunning { session.stopRunning() }
|
if session.isRunning { session.stopRunning() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func cancelTapped() {
|
@objc private func cancelTapped() {
|
||||||
onCancel?()
|
onCancel?()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestCamera() {
|
@objc private func openSettings() {
|
||||||
guard let device = AVCaptureDevice.default(for: .video) else {
|
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||||
showMessage("This device has no camera. Use “Photo” to pick a QR from the library instead.")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
case .authorized:
|
case .authorized:
|
||||||
configure(device: device)
|
start()
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
guard let self else { return }
|
||||||
if granted {
|
if granted {
|
||||||
self?.configure(device: device)
|
self.start()
|
||||||
} else {
|
} 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:
|
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) {
|
private func start() {
|
||||||
guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else {
|
statusLabel.isHidden = true
|
||||||
showMessage("The camera could not be started. Use “Photo” instead.")
|
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
|
return
|
||||||
}
|
}
|
||||||
session.addInput(input)
|
session.addInput(input)
|
||||||
|
|
||||||
let output = AVCaptureMetadataOutput()
|
let output = AVCaptureMetadataOutput()
|
||||||
guard session.canAddOutput(output) else {
|
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
|
return
|
||||||
}
|
}
|
||||||
session.addOutput(output)
|
session.addOutput(output)
|
||||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
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]
|
output.metadataObjectTypes = [.qr]
|
||||||
|
} else {
|
||||||
|
session.commitConfiguration()
|
||||||
|
showHelp("This camera cannot read QR codes.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.commitConfiguration()
|
||||||
|
|
||||||
let layer = AVCaptureVideoPreviewLayer(session: session)
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
layer.videoGravity = .resizeAspectFill
|
layer.videoGravity = .resizeAspectFill
|
||||||
layer.frame = view.bounds
|
layer.frame = view.layer.bounds
|
||||||
view.layer.insertSublayer(layer, at: 0)
|
view.layer.insertSublayer(layer, at: 0)
|
||||||
previewLayer = layer
|
previewLayer = layer
|
||||||
|
isConfigured = true
|
||||||
|
view.setNeedsLayout()
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
NotificationCenter.default.addObserver(self,
|
||||||
self?.session.startRunning()
|
selector: #selector(sessionFailed),
|
||||||
}
|
name: .AVCaptureSessionRuntimeError,
|
||||||
|
object: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showMessage(_ text: String) {
|
@objc private func sessionFailed(_ note: Notification) {
|
||||||
messageLabel?.text = text
|
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,
|
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||||
didOutput metadataObjects: [AVMetadataObject],
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
from connection: AVCaptureConnection) {
|
from connection: AVCaptureConnection) {
|
||||||
@@ -135,7 +220,10 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||||
let value = object.stringValue, !value.isEmpty else { return }
|
let value = object.stringValue, !value.isEmpty else { return }
|
||||||
hasScanned = true
|
hasScanned = true
|
||||||
session.stopRunning()
|
sessionQueue.async { [session] in
|
||||||
|
if session.isRunning { session.stopRunning() }
|
||||||
|
}
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
onScan?(value)
|
onScan?(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user