Tap-to-Focus war bereits verdrahtet, aber ohne sichtbare Rueckmeldung – daher wirkte es, als passiere nichts. Beim Antippen erscheint jetzt kurz ein gelber Fokus-Rahmen an der getippten Stelle (wie in der System-Kamera); die Kamera stellt weiterhin gezielt dorthin scharf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
8.9 KiB
Swift
230 lines
8.9 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
|
||
|
||
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
|
||
controller.setTorch(on: torchOn)
|
||
}
|
||
|
||
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
||
let onCode: (String) -> Void
|
||
var isPaused: Bool = false
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Licht-Schalter fuer die Scan-Bildschirme.
|
||
struct TorchButton: View {
|
||
@Binding var isOn: Bool
|
||
|
||
var body: some View {
|
||
Button {
|
||
isOn.toggle()
|
||
} label: {
|
||
Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill")
|
||
}
|
||
.accessibilityLabel(isOn ? "Licht ausschalten" : "Licht einschalten")
|
||
}
|
||
}
|
||
|
||
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?
|
||
|
||
/// 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)))
|
||
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()
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|