iOS pairing scanner: fix black preview on device
The feed stayed black even when the session reported running: the preview was a manually-framed sublayer (frame set in viewDidLoad/viewDidLayout, which raced the async permission callback and could end up zero-sized), and the session was configured across threads (addInput/Output on main, startRunning on a queue). Switch to the canonical AVFoundation pattern: the preview is now the view's backing layer (CameraPreviewView via layerClass), so it always fills the view with no frame bookkeeping, and all session configuration + start/stop run on a dedicated serial queue with delegate/state callbacks hopped to main. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d8fc4696a8
commit
b5853eee67
@@ -99,17 +99,34 @@ struct QRScannerRepresentable: UIViewControllerRepresentable {
|
|||||||
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
|
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A view whose *backing layer* is the camera preview, so it always fills the view through the
|
||||||
|
/// layer hierarchy — no manual frame bookkeeping. The prior version added the preview as a
|
||||||
|
/// sublayer and set its frame in viewDidLoad/viewDidLayoutSubviews, which raced with the async
|
||||||
|
/// permission callback and layout and could leave a zero-sized (black) preview on device.
|
||||||
|
final class CameraPreviewView: UIView {
|
||||||
|
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||||
|
}
|
||||||
|
|
||||||
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
var onFound: ((String) -> Void)?
|
var onFound: ((String) -> Void)?
|
||||||
var onState: ((ScannerCameraState) -> Void)?
|
var onState: ((ScannerCameraState) -> Void)?
|
||||||
private let session = AVCaptureSession()
|
private let session = AVCaptureSession()
|
||||||
|
/// AVCaptureSession isn't thread-safe — all configuration + start/stop happen here; only the
|
||||||
|
/// preview layer and delegate callbacks touch the main thread.
|
||||||
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
|
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
|
||||||
private var preview: AVCaptureVideoPreviewLayer?
|
|
||||||
private var didFind = false
|
private var didFind = false
|
||||||
|
|
||||||
|
private var previewView: CameraPreviewView { view as! CameraPreviewView }
|
||||||
|
|
||||||
|
override func loadView() { view = CameraPreviewView() }
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
|
previewView.previewLayer.session = session
|
||||||
|
previewView.previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
|
||||||
// Gate on camera authorization first — without this the prompt may never appear and the
|
// Gate on camera authorization first — without this the prompt may never appear and the
|
||||||
// feed silently fails (and on a denied device it would just stay black).
|
// feed silently fails (and on a denied device it would just stay black).
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
@@ -117,10 +134,8 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
|
|||||||
configureSession()
|
configureSession()
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
DispatchQueue.main.async {
|
|
||||||
granted ? self?.configureSession() : self?.report(.denied)
|
granted ? self?.configureSession() : self?.report(.denied)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case .denied, .restricted:
|
case .denied, .restricted:
|
||||||
report(.denied)
|
report(.denied)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -128,46 +143,40 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the capture graph. Reports `.unavailable` when there's no camera (the Simulator) so
|
/// Configure + start the capture graph on the session queue. Reports `.unavailable` when
|
||||||
/// the UI can explain the black screen instead of leaving the user guessing.
|
/// there's no usable camera (e.g. the Simulator) and `.scanning` once frames are flowing.
|
||||||
private func configureSession() {
|
private func configureSession() {
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.session.beginConfiguration()
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
let input = try? AVCaptureDeviceInput(device: device),
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
session.canAddInput(input) else {
|
self.session.canAddInput(input) else {
|
||||||
report(.unavailable); return
|
self.session.commitConfiguration(); self.report(.unavailable); return
|
||||||
}
|
}
|
||||||
session.addInput(input)
|
self.session.addInput(input)
|
||||||
|
|
||||||
let output = AVCaptureMetadataOutput()
|
let output = AVCaptureMetadataOutput()
|
||||||
guard session.canAddOutput(output) else { report(.unavailable); return }
|
guard self.session.canAddOutput(output) else {
|
||||||
session.addOutput(output)
|
self.session.commitConfiguration(); self.report(.unavailable); return
|
||||||
|
}
|
||||||
|
self.session.addOutput(output)
|
||||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
output.metadataObjectTypes = [.qr]
|
output.metadataObjectTypes = [.qr]
|
||||||
|
self.session.commitConfiguration()
|
||||||
|
|
||||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
self.session.startRunning()
|
||||||
preview.videoGravity = .resizeAspectFill
|
self.report(.scanning)
|
||||||
preview.frame = view.layer.bounds
|
|
||||||
view.layer.addSublayer(preview)
|
|
||||||
self.preview = preview
|
|
||||||
|
|
||||||
report(.scanning)
|
|
||||||
sessionQueue.async { [session] in session.startRunning() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
// Keep the preview filling the view across layout/rotation (setting it once in viewDidLoad
|
|
||||||
// left it mis-sized when bounds weren't final yet).
|
|
||||||
preview?.frame = view.layer.bounds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidDisappear(_ animated: Bool) {
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
sessionQueue.async { [session] in session.stopRunning() }
|
sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func report(_ state: ScannerCameraState) {
|
private func report(_ state: ScannerCameraState) {
|
||||||
onState?(state)
|
DispatchQueue.main.async { [weak self] in self?.onState?(state) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func metadataOutput(
|
func metadataOutput(
|
||||||
|
|||||||
Reference in New Issue
Block a user