155 lines
5.6 KiB
Swift
155 lines
5.6 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import VisionKit
|
||
|
||
struct BarcodeScannerView: View {
|
||
let onScanned: (String) -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
if DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
|
||
DataScannerRepresentable(onScanned: onScanned)
|
||
} else {
|
||
AVScannerRepresentable(onScanned: onScanned)
|
||
}
|
||
}
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.stroke(Color.white.opacity(0.75), lineWidth: 2)
|
||
}
|
||
.accessibilityLabel("ISBN barkod tarayıcı")
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, *)
|
||
private struct DataScannerRepresentable: UIViewControllerRepresentable {
|
||
let onScanned: (String) -> Void
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
|
||
|
||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||
let vc = DataScannerViewController(
|
||
recognizedDataTypes: [.barcode(symbologies: [.ean8, .ean13, .upce, .code128])],
|
||
qualityLevel: .balanced,
|
||
recognizesMultipleItems: false,
|
||
isHighFrameRateTrackingEnabled: true,
|
||
isPinchToZoomEnabled: true,
|
||
isGuidanceEnabled: true,
|
||
isHighlightingEnabled: true
|
||
)
|
||
vc.delegate = context.coordinator
|
||
try? vc.startScanning()
|
||
return vc
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
|
||
|
||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||
let onScanned: (String) -> Void
|
||
private var lastISBN: String?
|
||
private var lastEmitAt: Date = .distantPast
|
||
|
||
init(onScanned: @escaping (String) -> Void) {
|
||
self.onScanned = onScanned
|
||
}
|
||
|
||
func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
|
||
guard case .barcode(let code) = item,
|
||
let payload = code.payloadStringValue,
|
||
let normalized = ISBNNormalizer.normalize(payload) else { return }
|
||
emitIfNeeded(normalized)
|
||
}
|
||
|
||
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||
guard let first = addedItems.first,
|
||
case .barcode(let code) = first,
|
||
let payload = code.payloadStringValue,
|
||
let normalized = ISBNNormalizer.normalize(payload) else { return }
|
||
emitIfNeeded(normalized)
|
||
}
|
||
|
||
private func emitIfNeeded(_ isbn: String) {
|
||
let now = Date()
|
||
if lastISBN == isbn, now.timeIntervalSince(lastEmitAt) < 1.5 {
|
||
return
|
||
}
|
||
lastISBN = isbn
|
||
lastEmitAt = now
|
||
onScanned(isbn)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct AVScannerRepresentable: UIViewRepresentable {
|
||
let onScanned: (String) -> Void
|
||
|
||
func makeUIView(context: Context) -> ScannerPreviewView {
|
||
let view = ScannerPreviewView()
|
||
context.coordinator.configure(preview: view)
|
||
return view
|
||
}
|
||
|
||
func updateUIView(_ uiView: ScannerPreviewView, context: Context) {}
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
|
||
|
||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||
private let session = AVCaptureSession()
|
||
private let sessionQueue = DispatchQueue(label: "bookibra.av.capture.session")
|
||
private let onScanned: (String) -> Void
|
||
private var lastISBN: String?
|
||
private var lastEmitAt: Date = .distantPast
|
||
|
||
init(onScanned: @escaping (String) -> Void) {
|
||
self.onScanned = onScanned
|
||
}
|
||
|
||
func configure(preview: ScannerPreviewView) {
|
||
guard let device = AVCaptureDevice.default(for: .video),
|
||
let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||
|
||
if session.canAddInput(input) { session.addInput(input) }
|
||
|
||
let output = AVCaptureMetadataOutput()
|
||
if session.canAddOutput(output) {
|
||
session.addOutput(output)
|
||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||
output.metadataObjectTypes = [.ean8, .ean13, .upce, .code128]
|
||
}
|
||
|
||
preview.previewLayer.session = session
|
||
preview.previewLayer.videoGravity = .resizeAspectFill
|
||
sessionQueue.async { [session] in
|
||
session.startRunning()
|
||
}
|
||
}
|
||
|
||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||
guard let code = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first,
|
||
let value = code.stringValue,
|
||
let normalized = ISBNNormalizer.normalize(value) else { return }
|
||
let now = Date()
|
||
if lastISBN == normalized, now.timeIntervalSince(lastEmitAt) < 1.5 {
|
||
return
|
||
}
|
||
lastISBN = normalized
|
||
lastEmitAt = now
|
||
onScanned(normalized)
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class ScannerPreviewView: UIView {
|
||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||
}
|
||
|
||
enum ISBNNormalizer {
|
||
static func normalize(_ value: String) -> String? {
|
||
let cleaned = value.uppercased().filter { $0.isNumber || $0 == "X" }
|
||
if cleaned.count == 13 { return cleaned }
|
||
if cleaned.count == 10 { return cleaned }
|
||
return nil
|
||
}
|
||
}
|