229 lines
8.1 KiB
Swift
229 lines
8.1 KiB
Swift
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)
|
|
}
|
|
} |