import AVFoundation import SwiftUI /// Live-Kamerabild mit Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR). /// Meldet jeden erkannten Code einmal – erst nach `resume()` wieder. struct ScannerView: UIViewControllerRepresentable { var onCode: (String) -> Void @Binding var isPaused: Bool @Binding var torchOn: Bool /// Per Langdruck gesperrter Blitz bleibt auch nach einem Scan an. Standard: /// aus nach dem ersten Treffer. @Binding var torchLocked: Bool func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } func makeUIViewController(context: Context) -> ScannerViewController { let controller = ScannerViewController() controller.delegate = context.coordinator return controller } func updateUIViewController(_ controller: ScannerViewController, context: Context) { context.coordinator.isPaused = isPaused context.coordinator.torchLocked = torchLocked context.coordinator.torchBinding = $torchOn controller.setTorch(on: torchOn) } final class Coordinator: NSObject, ScannerViewControllerDelegate { let onCode: (String) -> Void var isPaused: Bool = false var torchLocked: Bool = false var torchBinding: Binding? private var lastCode: String? private var lastTime: Date = .distantPast init(onCode: @escaping (String) -> Void) { self.onCode = onCode } func scanner(_ controller: ScannerViewController, didFind code: String) { guard !isPaused else { return } // Entprellen: derselbe Code nicht mehrfach in kurzer Folge. if code == lastCode, Date().timeIntervalSince(lastTime) < 2 { return } lastCode = code lastTime = Date() AudioServicesPlaySystemSound(1057) onCode(code) // Blitz nach dem Scan ausschalten – ausser er ist per Langdruck gesperrt. if !torchLocked, torchBinding?.wrappedValue == true { DispatchQueue.main.async { [weak self] in self?.torchBinding?.wrappedValue = false } } } } } /// Licht-Schalter fuer die Scan-Bildschirme. /// /// Tippen: an/aus – angeschaltet geht der Blitz nach dem naechsten Scan wieder /// aus. Langdruck: dauerhaft an (gesperrt, gelb), bleibt auch nach Scans an; /// erneutes Tippen schaltet aus und hebt die Sperre auf. struct TorchButton: View { @Binding var isOn: Bool @Binding var locked: Bool var body: some View { Button { locked = false isOn.toggle() } label: { Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill") .foregroundStyle(locked ? Color.yellow : Color.marke) } .simultaneousGesture( LongPressGesture(minimumDuration: 0.4).onEnded { _ in isOn = true locked = true } ) .accessibilityLabel( isOn ? (locked ? "Licht dauerhaft an, zum Ausschalten tippen" : "Licht aus") : "Licht ein (bleibt bis zum nächsten Scan)" ) } } protocol ScannerViewControllerDelegate: AnyObject { func scanner(_ controller: ScannerViewController, didFind code: String) } final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { weak var delegate: ScannerViewControllerDelegate? private let session = AVCaptureSession() private var preview: AVCaptureVideoPreviewLayer? private var device: AVCaptureDevice? /// Zoomfaktor beim Beginn einer Pinch-Geste (Ausgangspunkt fuers Skalieren). private var zoomBasis: CGFloat = 1.0 /// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor, /// DataMatrix auf kleinen Aufklebern. private static let wantedTypes: [AVMetadataObject.ObjectType] = [ .ean13, .ean8, .upce, .code128, .code39, .code93, .itf14, .interleaved2of5, .dataMatrix, .pdf417, .aztec, .qr, ] override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap))) view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(zoomPinch))) AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in guard granted else { return } DispatchQueue.main.async { self?.configure() } } } private func configure() { let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) ?? AVCaptureDevice.default(for: .video) guard let camera, let input = try? AVCaptureDeviceInput(device: camera), session.canAddInput(input) else { return } device = camera session.beginConfiguration() // Mehr Pixel: kleine oder kontrastarme Codes werden sonst nicht sauber aufgeloest. if session.canSetSessionPreset(.hd1920x1080) { session.sessionPreset = .hd1920x1080 } else if session.canSetSessionPreset(.high) { session.sessionPreset = .high } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { session.commitConfiguration() return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) // Nur Typen setzen, die diese Kamera wirklich kann - sonst wirft AVFoundation. output.metadataObjectTypes = Self.wantedTypes.filter { output.availableMetadataObjectTypes.contains($0) } session.commitConfiguration() tuneForBarcodes(camera) let layer = AVCaptureVideoPreviewLayer(session: session) layer.videoGravity = .resizeAspectFill layer.frame = view.bounds view.layer.addSublayer(layer) preview = layer start() } /// Barcodes liegen dicht vor der Linse und sind manchmal schwach im Kontrast /// (z. B. dunkelgruen auf weiss). Nahbereich und staendiger Autofokus helfen. private func tuneForBarcodes(_ camera: AVCaptureDevice) { guard (try? camera.lockForConfiguration()) != nil else { return } if camera.isFocusModeSupported(.continuousAutoFocus) { camera.focusMode = .continuousAutoFocus } if camera.isAutoFocusRangeRestrictionSupported { camera.autoFocusRangeRestriction = .near } if camera.isExposureModeSupported(.continuousAutoExposure) { camera.exposureMode = .continuousAutoExposure } // Weiches Nachfuehren ist fuer Video gedacht und verzoegert hier nur. if camera.isSmoothAutoFocusSupported { camera.isSmoothAutoFocusEnabled = false } camera.unlockForConfiguration() } /// Antippen stellt gezielt auf den Code scharf - Rettungsanker fuer Codes, /// die der Autofokus nicht von selbst findet. Ein kurz aufblitzender Rahmen /// zeigt, wohin fokussiert wird (wie in der System-Kamera). @objc private func focusOnTap(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: view) showFocusIndicator(at: location) guard let device, let preview else { return } let point = preview.captureDevicePointConverted(fromLayerPoint: location) guard (try? device.lockForConfiguration()) != nil else { return } if device.isFocusPointOfInterestSupported, device.isFocusModeSupported(.autoFocus) { device.focusPointOfInterest = point device.focusMode = .autoFocus } if device.isExposurePointOfInterestSupported, device.isExposureModeSupported(.continuousAutoExposure) { device.exposurePointOfInterest = point device.exposureMode = .continuousAutoExposure } device.unlockForConfiguration() } /// Zwei-Finger-Pinch zoomt naeher an einen Code heran (rein digital, auf ein /// sinnvolles Maximum begrenzt – zu viel Zoom macht den Code unscharf). @objc private func zoomPinch(_ gesture: UIPinchGestureRecognizer) { guard let device else { return } if gesture.state == .began { zoomBasis = device.videoZoomFactor } let maxZoom = min(device.activeFormat.videoMaxZoomFactor, 6.0) let neu = max(1.0, min(zoomBasis * gesture.scale, maxZoom)) guard (try? device.lockForConfiguration()) != nil else { return } device.videoZoomFactor = neu device.unlockForConfiguration() } /// Kurzer gelber Fokus-Rahmen an der getippten Stelle als Rueckmeldung. private func showFocusIndicator(at point: CGPoint) { let seite: CGFloat = 72 let rahmen = UIView(frame: CGRect(x: 0, y: 0, width: seite, height: seite)) rahmen.center = point rahmen.backgroundColor = .clear rahmen.isUserInteractionEnabled = false rahmen.layer.borderColor = UIColor.systemYellow.cgColor rahmen.layer.borderWidth = 1.5 rahmen.layer.cornerRadius = 6 rahmen.alpha = 0 rahmen.transform = CGAffineTransform(scaleX: 1.4, y: 1.4) view.addSubview(rahmen) UIView.animate(withDuration: 0.2, animations: { rahmen.alpha = 1 rahmen.transform = .identity }, completion: { _ in UIView.animate(withDuration: 0.35, delay: 0.6, options: [], animations: { rahmen.alpha = 0 }, completion: { _ in rahmen.removeFromSuperview() }) }) } /// Licht an: hebt den Kontrast bei matten oder farbigen Codes deutlich. func setTorch(on: Bool) { guard let device, device.hasTorch, device.isTorchAvailable else { return } guard device.torchMode != (on ? .on : .off) else { return } guard (try? device.lockForConfiguration()) != nil else { return } device.torchMode = on ? .on : .off device.unlockForConfiguration() } func start() { guard !session.isRunning else { return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } } func stop() { guard session.isRunning else { return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.stopRunning() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); start() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) setTorch(on: false) // Licht nie versehentlich anlassen stop() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() preview?.frame = view.bounds } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let value = object.stringValue else { return } delegate?.scanner(self, didFind: value) } }