141 lines
4.6 KiB
Swift
141 lines
4.6 KiB
Swift
import AVFoundation
|
|
import SwiftUI
|
|
|
|
/// Camera QR scanner, presented as a sheet. On a simulator (no capture device)
|
|
/// it explains that instead of showing a dead viewfinder.
|
|
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 var previewLayer: AVCaptureVideoPreviewLayer?
|
|
private var hasScanned = false
|
|
private var messageLabel: UILabel?
|
|
|
|
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)
|
|
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
|
|
|
|
requestCamera()
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
previewLayer?.frame = view.bounds
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
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.")
|
|
return
|
|
}
|
|
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
configure(device: device)
|
|
case .notDetermined:
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
DispatchQueue.main.async {
|
|
if granted {
|
|
self?.configure(device: device)
|
|
} else {
|
|
self?.showMessage("Camera access is off. Enable it in Settings, or use “Photo”.")
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
showMessage("Camera access is off. Enable it in Settings, or use “Photo”.")
|
|
}
|
|
}
|
|
|
|
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.")
|
|
return
|
|
}
|
|
session.addInput(input)
|
|
|
|
let output = AVCaptureMetadataOutput()
|
|
guard session.canAddOutput(output) else {
|
|
showMessage("The camera could not be started. Use “Photo” instead.")
|
|
return
|
|
}
|
|
session.addOutput(output)
|
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
|
output.metadataObjectTypes = [.qr]
|
|
|
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
|
layer.videoGravity = .resizeAspectFill
|
|
layer.frame = view.bounds
|
|
view.layer.insertSublayer(layer, at: 0)
|
|
previewLayer = layer
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
self?.session.startRunning()
|
|
}
|
|
}
|
|
|
|
private func showMessage(_ text: String) {
|
|
messageLabel?.text = text
|
|
}
|
|
|
|
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
|
|
session.stopRunning()
|
|
onScan?(value)
|
|
}
|
|
} |