iOS: Scanner – Pinch-Zoom und schlaueres Blitzlicht
- 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>
This commit is contained in:
@@ -8,6 +8,7 @@ struct AssignScanView: View {
|
||||
|
||||
@State private var paused = false
|
||||
@State private var torchOn = false
|
||||
@State private var torchLocked = false
|
||||
@State private var pending: Item? // gescanntes Stück, wartet auf Ort
|
||||
@State private var status: String?
|
||||
@State private var error: String?
|
||||
@@ -16,7 +17,7 @@ struct AssignScanView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ScannerView(onCode: { code in Task { await handle(code) } },
|
||||
isPaused: $paused, torchOn: $torchOn)
|
||||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal)
|
||||
@@ -52,7 +53,7 @@ struct AssignScanView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ struct CheckInView: View {
|
||||
|
||||
@State private var paused = false
|
||||
@State private var torchOn = false
|
||||
@State private var torchLocked = false
|
||||
@State private var status: String?
|
||||
@State private var error: String?
|
||||
|
||||
@@ -27,7 +28,7 @@ struct CheckInView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||
isPaused: $paused, torchOn: $torchOn)
|
||||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal)
|
||||
@@ -74,7 +75,7 @@ struct CheckInView: View {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||
}
|
||||
.task {
|
||||
units = (try? await APIClient.shared.units()) ?? []
|
||||
|
||||
@@ -5,6 +5,7 @@ struct CheckOutView: View {
|
||||
|
||||
@State private var paused = false
|
||||
@State private var torchOn = false
|
||||
@State private var torchLocked = false
|
||||
@State private var status: String?
|
||||
@State private var error: String?
|
||||
@State private var product: Product?
|
||||
@@ -18,7 +19,7 @@ struct CheckOutView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||
isPaused: $paused, torchOn: $torchOn)
|
||||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal)
|
||||
@@ -56,7 +57,7 @@ struct CheckOutView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||
}
|
||||
.task { units = (try? await APIClient.shared.units()) ?? [] }
|
||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||
|
||||
@@ -8,6 +8,7 @@ struct LookupScanView: View {
|
||||
|
||||
@State private var paused = false
|
||||
@State private var torchOn = false
|
||||
@State private var torchLocked = false
|
||||
@State private var error: String?
|
||||
@State private var item: Item?
|
||||
@State private var product: Product?
|
||||
@@ -16,7 +17,7 @@ struct LookupScanView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||
isPaused: $paused, torchOn: $torchOn)
|
||||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal)
|
||||
@@ -36,7 +37,7 @@ struct LookupScanView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||
}
|
||||
.sheet(item: $item, onDismiss: { paused = false }) { it in
|
||||
NavigationStack { ItemEditView(item: it) }
|
||||
|
||||
@@ -7,6 +7,9 @@ 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) }
|
||||
|
||||
@@ -18,12 +21,16 @@ struct ScannerView: UIViewControllerRepresentable {
|
||||
|
||||
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
|
||||
|
||||
@@ -37,21 +44,42 @@ struct ScannerView: UIViewControllerRepresentable {
|
||||
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)
|
||||
}
|
||||
.accessibilityLabel(isOn ? "Licht ausschalten" : "Licht einschalten")
|
||||
.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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +93,8 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
||||
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.
|
||||
@@ -77,6 +107,7 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
||||
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() }
|
||||
@@ -164,6 +195,20 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user