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:
Nucleic
2026-06-21 16:38:00 -07:00
co-authored by Claude Opus 4.8
parent d8fc4696a8
commit b5853eee67
@@ -99,17 +99,34 @@ struct QRScannerRepresentable: UIViewControllerRepresentable {
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 {
var onFound: ((String) -> Void)?
var onState: ((ScannerCameraState) -> Void)?
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 var preview: AVCaptureVideoPreviewLayer?
private var didFind = false
private var previewView: CameraPreviewView { view as! CameraPreviewView }
override func loadView() { view = CameraPreviewView() }
override func viewDidLoad() {
super.viewDidLoad()
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
// feed silently fails (and on a denied device it would just stay black).
switch AVCaptureDevice.authorizationStatus(for: .video) {
@@ -117,9 +134,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
configureSession()
case .notDetermined:
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:
report(.denied)
@@ -128,46 +143,40 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
}
}
/// Build the capture graph. Reports `.unavailable` when there's no camera (the Simulator) so
/// the UI can explain the black screen instead of leaving the user guessing.
/// Configure + start the capture graph on the session queue. Reports `.unavailable` when
/// there's no usable camera (e.g. the Simulator) and `.scanning` once frames are flowing.
private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
report(.unavailable); return
sessionQueue.async { [weak self] in
guard let self else { return }
self.session.beginConfiguration()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
self.session.canAddInput(input) else {
self.session.commitConfiguration(); self.report(.unavailable); return
}
self.session.addInput(input)
let output = AVCaptureMetadataOutput()
guard self.session.canAddOutput(output) else {
self.session.commitConfiguration(); self.report(.unavailable); return
}
self.session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
self.session.commitConfiguration()
self.session.startRunning()
self.report(.scanning)
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { report(.unavailable); return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
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) {
super.viewDidDisappear(animated)
sessionQueue.async { [session] in session.stopRunning() }
sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } }
}
private func report(_ state: ScannerCameraState) {
onState?(state)
DispatchQueue.main.async { [weak self] in self?.onState?(state) }
}
func metadataOutput(