- Zwei-Finger-Pinch zoomt im Kamerabild naeher an einen Code heran (digital, auf Faktor 6 begrenzt). - Blitz geht nach dem ersten erfolgreichen Scan wieder aus. Per Langdruck auf den Blitz-Knopf bleibt er dauerhaft an (gesperrt, gelb); Tippen schaltet aus und hebt die Sperre auf. Gilt fuer alle Scan-Bildschirme. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
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<Bool>?
|
||
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.accentColor)
|
||
}
|
||
.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)
|
||
}
|
||
}
|