import SwiftUI import AVFoundation internal import Combine // 通知名称扩展 extension Notification.Name { static let scannerDidScanCode = Notification.Name("scannerDidScanCode") } // 检测到的条码数据结构 struct DetectedCode: Identifiable { let id = UUID() let type: String let content: String let bounds: CGRect } struct ScannerView: View { @StateObject private var scannerViewModel = ScannerViewModel() @Environment(\.dismiss) private var dismiss @State private var showPreviewPause = false @State private var previewLayer: AVCaptureVideoPreviewLayer? @State private var screenOrientation = UIDevice.current.orientation var body: some View { ZStack { // 相机预览层 CameraPreviewView(session: scannerViewModel.captureSession, previewLayer: $previewLayer) .ignoresSafeArea() // 扫描框覆盖层 VStack { Spacer() // 扫描框 ZStack { RoundedRectangle(cornerRadius: 20) .stroke(Color.white, lineWidth: 3) .frame(width: 250, height: 250) .background(Color.black.opacity(0.3)) .cornerRadius(20) // 扫描线动画 if !showPreviewPause { Rectangle() .fill(LinearGradient( colors: [Color.clear, Color.green, Color.clear], startPoint: .top, endPoint: .bottom )) .frame(width: 250, height: 2) .offset(y: -125) .animation( Animation.linear(duration: 2) .repeatForever(autoreverses: false), value: UUID() ) } } // 提示文本 if showPreviewPause { VStack(spacing: 8) { Text("检测到条码") .foregroundColor(.white) .font(.headline) if scannerViewModel.detectedCodes.count == 1 { Text("1秒后自动显示结果") .foregroundColor(.green) .font(.subheadline) } else { Text("点击绿色标记选择要解码的条码") .foregroundColor(.white.opacity(0.8)) .font(.subheadline) } } .padding(.top, 20) } else { Text("将二维码或条形码放入框内") .foregroundColor(.white) .font(.headline) .padding(.top, 20) } Spacer() // 底部按钮区域 VStack(spacing: 15) { if showPreviewPause { // 预览暂停时的按钮 Button("重新扫描") { resetToScanning() } .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) .background(Color.blue) .cornerRadius(20) } // 关闭按钮 Button("关闭") { dismiss() } .foregroundColor(.white) .padding() .background(Color.black.opacity(0.6)) .cornerRadius(10) } .padding(.bottom, 50) } // 条码位置标记覆盖层(支持点击选择) if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty { CodePositionOverlay( detectedCodes: scannerViewModel.detectedCodes, previewLayer: previewLayer, onCodeSelected: { selectedCode in NotificationCenter.default.post(name: .scannerDidScanCode, object: selectedCode) dismiss() } ) } // 测试按钮(用于调试单个条码自动选择) if showPreviewPause && scannerViewModel.detectedCodes.count == 1 { VStack { HStack { Spacer() Button("测试自动选择") { let code = scannerViewModel.detectedCodes[0] let selectedCode = "类型: \(code.type)\n内容: \(code.content)" NotificationCenter.default.post(name: .scannerDidScanCode, object: selectedCode) dismiss() } .foregroundColor(.white) .padding(8) .background(Color.red) .cornerRadius(8) .padding(.trailing, 20) } Spacer() } } } .onAppear { scannerViewModel.startScanning() } .onDisappear { scannerViewModel.stopScanning() } .alert("扫描失败", isPresented: $scannerViewModel.showAlert) { Button("确定") { dismiss() } } message: { Text("您的设备不支持扫描二维码。请使用带相机的设备。") } .onReceive(scannerViewModel.$detectedCodes) { codes in if !codes.isEmpty { print("检测到条码数量: \(codes.count)") if codes.count == 1 { // 只有一个码,显示标记后一秒自动显示结果 print("单个条码,准备自动选择") pauseForPreview() autoSelectSingleCode(code: codes[0]) } else { // 多个码,暂停预览并引导用户选择 print("多个条码,等待用户选择") pauseForPreview() } } } .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in // 屏幕方向变化时更新状态 screenOrientation = UIDevice.current.orientation print("Screen orientation changed to: \(screenOrientation.rawValue)") } } private func pauseForPreview() { showPreviewPause = true // 移除自动显示选择界面的定时器,用户必须手动选择 } private func resetToScanning() { showPreviewPause = false scannerViewModel.resetDetection() scannerViewModel.startScanning() } private func autoSelectSingleCode(code: DetectedCode) { print("开始自动选择定时器,条码类型: \(code.type)") // 一秒后自动选择单个条码 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { print("自动选择定时器触发") print("当前状态 - showPreviewPause: \(self.showPreviewPause)") print("当前条码数量: \(self.scannerViewModel.detectedCodes.count)") if self.showPreviewPause && self.scannerViewModel.detectedCodes.count == 1 { print("条件满足,执行自动选择") // 确保仍然只有一个条码且处于预览暂停状态 let selectedCode = "类型: \(code.type)\n内容: \(code.content)" print("发送通知: \(selectedCode)") NotificationCenter.default.post(name: .scannerDidScanCode, object: selectedCode) self.dismiss() } else { print("条件不满足,取消自动选择") } } } } // 相机预览视图 struct CameraPreviewView: UIViewRepresentable { let session: AVCaptureSession @Binding var previewLayer: AVCaptureVideoPreviewLayer? func makeUIView(context: Context) -> UIView { let view = UIView() let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.frame = view.bounds previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) // 保存预览层引用 DispatchQueue.main.async { self.previewLayer = previewLayer } return view } func updateUIView(_ uiView: UIView, context: Context) { if let previewLayer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer { previewLayer.frame = uiView.bounds // 通知预览层尺寸变化 DispatchQueue.main.async { self.previewLayer = previewLayer } } } } // 扫描器视图模型 class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { @Published var detectedCodes: [DetectedCode] = [] @Published var showAlert = false var captureSession: AVCaptureSession! private var metadataOutput: AVCaptureMetadataOutput? override init() { super.init() setupCaptureSession() } private func setupCaptureSession() { captureSession = AVCaptureSession() guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { showAlert = true return } let videoInput: AVCaptureDeviceInput do { videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) } catch { showAlert = true return } if captureSession.canAddInput(videoInput) { captureSession.addInput(videoInput) } else { showAlert = true return } metadataOutput = AVCaptureMetadataOutput() if let metadataOutput = metadataOutput, captureSession.canAddOutput(metadataOutput) { captureSession.addOutput(metadataOutput) metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) metadataOutput.metadataObjectTypes = [.qr, .ean8, .ean13, .code128, .code39, .upce, .pdf417, .aztec] } else { showAlert = true return } } func startScanning() { DispatchQueue.global(qos: .background).async { [weak self] in self?.captureSession?.startRunning() } } func stopScanning() { DispatchQueue.global(qos: .background).async { [weak self] in self?.captureSession?.stopRunning() } } func resetDetection() { DispatchQueue.main.async { self.detectedCodes = [] } } // MARK: - AVCaptureMetadataOutputObjectsDelegate func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { print("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象") // 震动反馈 AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) // 停止扫描 stopScanning() // 处理所有检测到的条码 var codes: [DetectedCode] = [] for metadataObject in metadataObjects { if let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, let stringValue = readableObject.stringValue { let codeType = readableObject.type.rawValue let bounds = readableObject.bounds let detectedCode = DetectedCode( type: codeType, content: stringValue, bounds: bounds ) codes.append(detectedCode) print("创建 DetectedCode: 类型=\(codeType), 内容=\(stringValue)") } } print("准备更新 detectedCodes,数量: \(codes.count)") // 更新检测到的条码列表 DispatchQueue.main.async { print("在主线程更新 detectedCodes") self.detectedCodes = codes } } } // 条码位置标记覆盖层 struct CodePositionOverlay: View { let detectedCodes: [DetectedCode] let previewLayer: AVCaptureVideoPreviewLayer? let onCodeSelected: (String) -> Void var body: some View { GeometryReader { geometry in ZStack { ForEach(detectedCodes) { code in CodePositionMarker( code: code, screenSize: geometry.size, previewLayer: previewLayer, onCodeSelected: onCodeSelected ) } } } .allowsHitTesting(true) // 允许触摸事件 } } // 单个条码位置标记 struct CodePositionMarker: View { let code: DetectedCode let screenSize: CGSize let previewLayer: AVCaptureVideoPreviewLayer? let onCodeSelected: (String) -> Void var body: some View { GeometryReader { geometry in // 使用GeometryReader获取实时尺寸 let position = calculatePosition(screenSize: geometry.size) ZStack { // 外圈 Circle() .stroke(Color.green, lineWidth: 3) .frame(width: 40, height: 40) // 内圈 Circle() .fill(Color.green.opacity(0.3)) .frame(width: 20, height: 20) // 中心点 Circle() .fill(Color.green) .frame(width: 6, height: 6) } .position(x: position.x, y: position.y) .background( // 透明的点击区域背景,增大点击范围 Circle() .fill(Color.clear) .frame(width: 60, height: 60) ) .onTapGesture { // 点击标记时选择条码 let selectedCode = "类型: \(code.type)\n内容: \(code.content)" onCodeSelected(selectedCode) } .onAppear { print("CodePositionMarker appeared at: x=\(position.x), y=\(position.y)") print("Screen size: \(geometry.size)") print("Code bounds: \(code.bounds)") } } } private func calculatePosition(screenSize: CGSize) -> CGPoint { guard let previewLayer = previewLayer else { // 如果没有预览层,使用屏幕中心 print("No preview layer available, using screen center") return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) } // 检查预览层是否有效 guard previewLayer.session?.isRunning == true else { print("Preview layer session not running, using screen center") return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) } // 使用AVFoundation的坐标转换方法 let metadataObject = code.bounds let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint( x: metadataObject.midX, y: metadataObject.midY )) // 验证转换结果是否有效 guard convertedPoint.x.isFinite && convertedPoint.y.isFinite else { print("Invalid converted point: \(convertedPoint), using screen center") return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) } // 确保位置在屏幕范围内 let clampedX = max(20, min(screenSize.width - 20, convertedPoint.x)) let clampedY = max(20, min(screenSize.height - 20, convertedPoint.y)) print("AVFoundation bounds: \(code.bounds)") print("Converted point: \(convertedPoint)") print("Screen size: \(screenSize)") print("Clamped: x=\(clampedX), y=\(clampedY)") return CGPoint(x: clampedX, y: clampedY) } } #Preview { ScannerView() }