From fd18b7b6835e9c17defeaf772a4148a1015dd4fa Mon Sep 17 00:00:00 2001 From: v504 Date: Wed, 20 Aug 2025 14:06:19 +0800 Subject: [PATCH] Remove ScannerView and its associated components; this includes the camera handling, UI elements, and scanning logic, streamlining the project by eliminating unused code. --- MyQrCode/ScannerView.swift | 1058 ----------------- .../ScannerView/CameraPermissionView.swift | 81 ++ MyQrCode/ScannerView/CameraPreviewView.swift | 32 + .../ScannerView/CodePositionOverlay.swift | 213 ++++ MyQrCode/ScannerView/Models.swift | 15 + MyQrCode/ScannerView/ScannerView.swift | 170 +++ MyQrCode/ScannerView/ScannerViewModel.swift | 280 +++++ MyQrCode/ScannerView/ScanningLineView.swift | 141 +++ .../ScannerView/ScanningOverlayView.swift | 117 ++ .../ScannerView/TestAutoSelectButton.swift | 24 + docs/SCANNER_VIEW_REFACTOR_README.md | 203 ++++ 11 files changed, 1276 insertions(+), 1058 deletions(-) delete mode 100644 MyQrCode/ScannerView.swift create mode 100644 MyQrCode/ScannerView/CameraPermissionView.swift create mode 100644 MyQrCode/ScannerView/CameraPreviewView.swift create mode 100644 MyQrCode/ScannerView/CodePositionOverlay.swift create mode 100644 MyQrCode/ScannerView/Models.swift create mode 100644 MyQrCode/ScannerView/ScannerView.swift create mode 100644 MyQrCode/ScannerView/ScannerViewModel.swift create mode 100644 MyQrCode/ScannerView/ScanningLineView.swift create mode 100644 MyQrCode/ScannerView/ScanningOverlayView.swift create mode 100644 MyQrCode/ScannerView/TestAutoSelectButton.swift create mode 100644 docs/SCANNER_VIEW_REFACTOR_README.md diff --git a/MyQrCode/ScannerView.swift b/MyQrCode/ScannerView.swift deleted file mode 100644 index 5e07cad..0000000 --- a/MyQrCode/ScannerView.swift +++ /dev/null @@ -1,1058 +0,0 @@ -import SwiftUI -import AVFoundation -import AudioToolbox -import Combine - -// MARK: - 主扫描视图 -struct ScannerView: View { - @StateObject private var scannerViewModel = ScannerViewModel() - @State private var showPreviewPause = false - @State private var selectedScanningStyle: ScanningLineStyle = .modern - @State private var screenOrientation = UIDevice.current.orientation - @State private var previewLayer: AVCaptureVideoPreviewLayer? - - @Environment(\.dismiss) private var dismiss - - var body: some View { - ZStack { - // 相机权限检查 - if scannerViewModel.cameraAuthorizationStatus == .authorized { - // 相机预览层 - CameraPreviewView(session: scannerViewModel.captureSession, previewLayer: $previewLayer) - .ignoresSafeArea() - - // 扫描界面覆盖层 - ScanningOverlayView( - showPreviewPause: showPreviewPause, - selectedStyle: $selectedScanningStyle, - detectedCodesCount: scannerViewModel.detectedCodes.count, - onClose: { dismiss() } - ) - - // 条码位置标记覆盖层 - if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty { - CodePositionOverlay( - detectedCodes: scannerViewModel.detectedCodes, - previewLayer: previewLayer, - onCodeSelected: handleCodeSelection, - onRescan: resetToScanning - ) - } - - // 测试按钮(调试用) - if showPreviewPause && scannerViewModel.detectedCodes.count == 1 { - TestAutoSelectButton( - detectedCode: scannerViewModel.detectedCodes[0], - onSelect: handleCodeSelection - ) - } - } else { - // 权限未授权时的UI - CameraPermissionView( - authorizationStatus: scannerViewModel.cameraAuthorizationStatus, - onRequestPermission: { - scannerViewModel.refreshCameraPermission() - }, - onOpenSettings: { - scannerViewModel.openSettings() - } - ) - } - } - .onAppear { - // 只有在相机权限已授权时才启动扫描 - if scannerViewModel.cameraAuthorizationStatus == .authorized { - scannerViewModel.startScanning() - } - } - .onDisappear { - scannerViewModel.stopScanning() - } - .alert("scan_error_title".localized, isPresented: $scannerViewModel.showAlert) { - Button("OK") { } - } message: { - Text("scan_error_message".localized) - } - .onReceive(scannerViewModel.$detectedCodes) { codes in - handleDetectedCodes(codes) - } - .onReceive(scannerViewModel.$cameraAuthorizationStatus) { status in - if status == .authorized { - logInfo("🎯 相机权限已授权,启动扫描", className: "ScannerView") - scannerViewModel.startScanning() - } - } - .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - handleOrientationChange() - } - } - - // MARK: - 私有方法 - - private func handleDetectedCodes(_ codes: [DetectedCode]) { - guard !codes.isEmpty else { return } - - logInfo("检测到条码数量: \(codes.count)", className: "ScannerView") - - if codes.count == 1 { - logInfo("单个条码,准备自动选择", className: "ScannerView") - pauseForPreview() - autoSelectSingleCode(code: codes[0]) - } else { - logInfo("多个条码,等待用户选择", className: "ScannerView") - pauseForPreview() - } - } - - private func handleOrientationChange() { - screenOrientation = UIDevice.current.orientation - logInfo("Screen orientation changed to: \(screenOrientation.rawValue)", className: "ScannerView") - } - - private func handleCodeSelection(_ selectedCode: DetectedCode) { - logInfo("🎯 ScannerView 收到条码选择回调", className: "ScannerView") - logInfo(" 选择的条码ID: \(selectedCode.id)", className: "ScannerView") - logInfo(" 选择的条码类型: \(selectedCode.type)", className: "ScannerView") - logInfo(" 选择的条码内容: \(selectedCode.content)", className: "ScannerView") - logInfo(" 选择的条码位置: \(selectedCode.bounds)", className: "ScannerView") - - let formattedResult = "类型: \(selectedCode.type)\n内容: \(selectedCode.content)" - logInfo(" 格式化结果: \(formattedResult)", className: "ScannerView") - - NotificationCenter.default.post(name: .scannerDidScanCode, object: formattedResult) - dismiss() - } - - private func pauseForPreview() { - showPreviewPause = true - } - - private func resetToScanning() { - logInfo("🔄 ScannerView 开始重置到扫描状态", className: "ScannerView") - - // 重置UI状态 - showPreviewPause = false - - // 重置扫描状态并重新开始 - scannerViewModel.resetDetection() - scannerViewModel.restartScanning() - - // 延迟检查会话状态 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - logInfo("🔍 检查扫描会话状态", className: "ScannerView") - self.scannerViewModel.checkSessionStatus() - } - - logInfo("✅ ScannerView 已重置到扫描状态", className: "ScannerView") - } - - private func autoSelectSingleCode(code: DetectedCode) { - logInfo("开始自动选择定时器,条码类型: \(code.type)", className: "ScannerView") - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - guard self.showPreviewPause && self.scannerViewModel.detectedCodes.count == 1 else { - logInfo("条件不满足,取消自动选择", className: "ScannerView") - return - } - - logInfo("条件满足,执行自动选择", className: "ScannerView") - self.handleCodeSelection(code) - } - } -} - -// MARK: - 扫描界面覆盖层 -struct ScanningOverlayView: View { - let showPreviewPause: Bool - @Binding var selectedStyle: ScanningLineStyle - let detectedCodesCount: Int - let onClose: () -> Void - - var body: some View { - VStack { - Spacer() - - // 扫描线组件 - if !showPreviewPause { - ScanningLineView(style: selectedStyle) - } - - // 提示文本 - ScanningInstructionView( - showPreviewPause: showPreviewPause, - detectedCodesCount: detectedCodesCount - ) - - Spacer() - - // 底部按钮区域 - ScanningBottomButtonsView( - showPreviewPause: showPreviewPause, - selectedStyle: $selectedStyle, - onClose: onClose - ) - } - } -} - -// MARK: - 扫描指令视图 -struct ScanningInstructionView: View { - let showPreviewPause: Bool - let detectedCodesCount: Int - - var body: some View { - if showPreviewPause { - VStack(spacing: 8) { - Text("detected_codes".localized) - .foregroundColor(.white) - .font(.headline) - - if detectedCodesCount == 1 { - Text("auto_result_1s".localized) - .foregroundColor(.green) - .font(.subheadline) - } else { - Text("select_code_instruction".localized) - .foregroundColor(.white.opacity(0.8)) - .font(.subheadline) - } - } - .padding(.top, 20) - } else { - Text("scan_instruction".localized) - .foregroundColor(.white) - .font(.headline) - .padding(.top, 20) - } - } -} - -// MARK: - 扫描底部按钮视图 -struct ScanningBottomButtonsView: View { - let showPreviewPause: Bool - @Binding var selectedStyle: ScanningLineStyle - let onClose: () -> Void - - var body: some View { - VStack(spacing: 15) { - // 扫描线样式选择器 - if !showPreviewPause { - ScanningStyleSelectorView(selectedStyle: $selectedStyle) - } - - // 关闭按钮 - 只在非预览选择状态时显示 - if !showPreviewPause { - Button("close_button".localized) { - onClose() - } - .foregroundColor(.white) - .padding() - .background(Color.black.opacity(0.6)) - .cornerRadius(10) - } - } - .padding(.bottom, 50) - } -} - -// MARK: - 扫描线样式选择器 -struct ScanningStyleSelectorView: View { - @Binding var selectedStyle: ScanningLineStyle - - var body: some View { - HStack(spacing: 10) { - ForEach(ScanningLineStyle.allCases, id: \.self) { style in - Button(style.localizedName) { - selectedStyle = style - } - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(selectedStyle == style ? Color.green : Color.gray.opacity(0.6)) - .cornerRadius(8) - .font(.caption) - } - } - .padding(.bottom, 10) - } -} - -// MARK: - 测试自动选择按钮 -struct TestAutoSelectButton: View { - let detectedCode: DetectedCode - let onSelect: (DetectedCode) -> Void - - var body: some View { - VStack { - HStack { - Spacer() - Button("test_auto_select".localized) { - onSelect(detectedCode) - } - .foregroundColor(.white) - .padding(8) - .background(Color.red) - .cornerRadius(8) - .padding(.trailing, 20) - } - Spacer() - } - } -} - -// MARK: - 相机预览视图 -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 - } - } - } -} - -// MARK: - 扫描器视图模型 -class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { - @Published var detectedCodes: [DetectedCode] = [] - @Published var showAlert = false - @Published var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined - @Published var showPermissionAlert = false - - var captureSession: AVCaptureSession! - private var metadataOutput: AVCaptureMetadataOutput? - - override init() { - super.init() - checkCameraPermission() - } - - // MARK: - 相机权限管理 - - private func checkCameraPermission() { - logInfo("🔍 检查相机权限状态", className: "ScannerViewModel") - - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - logInfo("✅ 相机权限已授权", className: "ScannerViewModel") - cameraAuthorizationStatus = .authorized - setupCaptureSession() - - case .notDetermined: - logInfo("❓ 相机权限未确定,请求权限", className: "ScannerViewModel") - requestCameraPermission() - - case .denied, .restricted: - logWarning("❌ 相机权限被拒绝或受限", className: "ScannerViewModel") - cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - showPermissionAlert = true - - @unknown default: - logWarning("❓ 未知的相机权限状态", className: "ScannerViewModel") - cameraAuthorizationStatus = .notDetermined - } - } - - private func requestCameraPermission() { - logInfo("🔐 请求相机权限", className: "ScannerViewModel") - - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - DispatchQueue.main.async { - if granted { - logInfo("✅ 相机权限请求成功", className: "ScannerViewModel") - self?.cameraAuthorizationStatus = .authorized - self?.setupCaptureSession() - } else { - logWarning("❌ 相机权限请求被拒绝", className: "ScannerViewModel") - self?.cameraAuthorizationStatus = .denied - self?.showPermissionAlert = true - } - } - } - } - - func openSettings() { - logInfo("⚙️ 打开系统设置", className: "ScannerViewModel") - - if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(settingsUrl) { success in - if success { - logInfo("✅ 成功打开系统设置", className: "ScannerViewModel") - } else { - logWarning("⚠️ 打开系统设置失败", className: "ScannerViewModel") - } - } - } - } - - func refreshCameraPermission() { - logInfo("🔍 重新检查相机权限状态", className: "ScannerViewModel") - checkCameraPermission() - } - - // MARK: - 相机设置 - - 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 - } - } - - // MARK: - 扫描控制 - - func startScanning() { - logInfo("🔄 开始扫描", className: "ScannerViewModel") - - // 检查会话是否已经在运行 - if captureSession?.isRunning == true { - logInfo("ℹ️ 扫描会话已经在运行", className: "ScannerViewModel") - return - } - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } - - self.captureSession?.startRunning() - - // 检查启动状态 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if self.captureSession?.isRunning == true { - logInfo("✅ 扫描会话启动成功", className: "ScannerViewModel") - } else { - logWarning("⚠️ 扫描会话启动失败", className: "ScannerViewModel") - } - } - } - } - - func stopScanning() { - logInfo("🔄 停止扫描", className: "ScannerViewModel") - - // 检查会话是否在运行 - if captureSession?.isRunning == true { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.captureSession?.stopRunning() - - DispatchQueue.main.async { - logInfo("✅ 扫描会话已停止", className: "ScannerViewModel") - } - } - } else { - logInfo("ℹ️ 扫描会话已经停止", className: "ScannerViewModel") - } - } - - func resetDetection() { - DispatchQueue.main.async { - logInfo("🔄 重置检测状态,清空 detectedCodes", className: "ScannerViewModel") - self.detectedCodes = [] - } - } - - func isSessionRunning() -> Bool { - return captureSession?.isRunning == true - } - - func checkSessionStatus() { - let isRunning = captureSession?.isRunning == true - logInfo("📊 扫描会话状态检查: \(isRunning ? "运行中" : "已停止")", className: "ScannerViewModel") - - if !isRunning { - logWarning("⚠️ 扫描会话未运行,尝试重新启动", className: "ScannerViewModel") - startScanning() - } - } - - func restartScanning() { - logInfo("🔄 重新开始扫描", className: "ScannerViewModel") - - // 确保在主线程执行UI相关操作 - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // 先停止当前会话 - if self.captureSession?.isRunning == true { - logInfo("🔄 停止当前运行的扫描会话", className: "ScannerViewModel") - self.captureSession?.stopRunning() - } - - // 重置检测状态 - self.detectedCodes = [] - - // 延迟后重新启动会话 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - - logInfo("🔄 准备重新启动扫描会话", className: "ScannerViewModel") - - // 在后台线程启动会话 - DispatchQueue.global(qos: .userInitiated).async { - self.captureSession?.startRunning() - - // 检查会话状态 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if self.captureSession?.isRunning == true { - logInfo("✅ 扫描会话已成功重新启动", className: "ScannerViewModel") - } else { - logWarning("⚠️ 扫描会话启动失败,尝试重新启动", className: "ScannerViewModel") - // 如果启动失败,再次尝试 - DispatchQueue.global(qos: .userInitiated).async { - self.captureSession?.startRunning() - DispatchQueue.main.async { - if self.captureSession?.isRunning == true { - logInfo("✅ 扫描会话第二次尝试启动成功", className: "ScannerViewModel") - } else { - logError("❌ 扫描会话启动失败", className: "ScannerViewModel") - } - } - } - } - } - } - } - } - } - - // MARK: - AVCaptureMetadataOutputObjectsDelegate - - func metadataOutput(_ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection) { - - logInfo("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象", className: "ScannerViewModel") - - // 震动反馈 - 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) - logInfo("创建 DetectedCode: 类型=\(codeType), 内容=\(stringValue)", className: "ScannerViewModel") - } - } - - logInfo("准备更新 detectedCodes,数量: \(codes.count)", className: "ScannerViewModel") - - // 更新检测到的条码列表 - DispatchQueue.main.async { - logInfo("在主线程更新 detectedCodes", className: "ScannerViewModel") - self.detectedCodes = codes - } - } -} - -// MARK: - 条码位置标记覆盖层 -struct CodePositionOverlay: View { - let detectedCodes: [DetectedCode] - let previewLayer: AVCaptureVideoPreviewLayer? - let onCodeSelected: (DetectedCode) -> Void - let onRescan: () -> Void - - var body: some View { - GeometryReader { geometry in - ZStack { - ForEach(detectedCodes) { code in - CodePositionMarker( - code: code, - screenSize: geometry.size, - previewLayer: previewLayer, - onCodeSelected: onCodeSelected - ) - } - - // 调试信息:显示触摸区域边界 - #if DEBUG - ForEach(detectedCodes) { code in - let position = calculateDebugPosition(code: code, screenSize: geometry.size) - Rectangle() - .stroke(Color.red, lineWidth: 1) - .frame(width: 80, height: 80) - .position(x: position.x, y: position.y) - .opacity(0.3) - } - #endif - - // 重新扫描按钮 - 放在右上角 - VStack { - HStack { - Spacer() - Button(action: { - logInfo("🔄 用户点击重新扫描按钮", className: "CodePositionOverlay") - - // 添加触觉反馈 - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - - onRescan() - }) { - HStack(spacing: 8) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 18, weight: .semibold)) - .rotationEffect(.degrees(0)) - .animation(.easeInOut(duration: 0.3), value: true) - - Text("rescan_button".localized) - .font(.system(size: 15, weight: .semibold)) - } - .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 25) - .fill(Color.blue.opacity(0.9)) - .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) - ) - } - .buttonStyle(RescanButtonStyle()) - .padding(.trailing, 25) - .padding(.top, 25) - - // 调试按钮:检查会话状态 - #if DEBUG - Button(action: { - logInfo("🔍 调试:检查扫描会话状态", className: "CodePositionOverlay") - // 这里可以添加会话状态检查逻辑 - }) { - Image(systemName: "info.circle") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.yellow) - .padding(8) - .background(Color.black.opacity(0.6)) - .clipShape(Circle()) - } - .padding(.trailing, 10) - .padding(.top, 25) - #endif - } - Spacer() - } - } - } - .allowsHitTesting(true) - .contentShape(Rectangle()) // 确保整个区域都可以接收触摸事件 - .zIndex(1000) // 确保在最上层,不被其他视图遮挡 - } - - // 调试用的位置计算方法 - private func calculateDebugPosition(code: DetectedCode, screenSize: CGSize) -> CGPoint { - guard let previewLayer = previewLayer else { - return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) - } - - let metadataObject = code.bounds - let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint( - x: metadataObject.midX, - y: metadataObject.midY - )) - - let clampedX = max(40, min(screenSize.width - 40, convertedPoint.x)) - let clampedY = max(40, min(screenSize.height - 40, convertedPoint.y)) - - return CGPoint(x: clampedX, y: clampedY) - } -} - -// MARK: - 单个条码位置标记 -struct CodePositionMarker: View { - let code: DetectedCode - let screenSize: CGSize - let previewLayer: AVCaptureVideoPreviewLayer? - let onCodeSelected: (DetectedCode) -> Void - - var body: some View { - GeometryReader { geometry in - let position = calculatePosition(screenSize: geometry.size) - - ZStack { - // 触摸区域背景(透明,但可以接收触摸事件) - Circle() - .fill(Color.clear) - .frame(width: 80, height: 80) - .contentShape(Circle()) - .onTapGesture { - logDebug("🎯 CodePositionMarker 被点击!", className: "CodePositionMarker") - logDebug(" 条码ID: \(code.id)", className: "CodePositionMarker") - logDebug(" 条码类型: \(code.type)", className: "CodePositionMarker") - logDebug(" 条码内容: \(code.content)", className: "CodePositionMarker") - logDebug(" 点击位置: x=\(position.x), y=\(position.y)", className: "CodePositionMarker") - - // 添加触觉反馈 - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - - onCodeSelected(code) - } - - // 外圈 - 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) - .zIndex(1001) // 确保触摸区域在最上层 - .onAppear { - logDebug("CodePositionMarker appeared at: x=\(position.x), y=\(position.y)", className: "CodePositionMarker") - logDebug("Screen size: \(geometry.size)", className: "CodePositionMarker") - logDebug("Code bounds: \(code.bounds)", className: "CodePositionMarker") - } - } - } - - private func calculatePosition(screenSize: CGSize) -> CGPoint { - guard let previewLayer = previewLayer else { - logWarning("No preview layer available, using screen center", className: "CodePositionMarker") - return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) - } - - guard previewLayer.session?.isRunning == true else { - logWarning("Preview layer session not running, using screen center", className: "CodePositionMarker") - return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) - } - - let metadataObject = code.bounds - let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint( - x: metadataObject.midX, - y: metadataObject.midY - )) - - guard convertedPoint.x.isFinite && convertedPoint.y.isFinite else { - logWarning("Invalid converted point: \(convertedPoint), using screen center", className: "CodePositionMarker") - 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)) - - logDebug("AVFoundation bounds: \(code.bounds)", className: "CodePositionMarker") - logDebug("Converted point: \(convertedPoint)", className: "CodePositionMarker") - logDebug("Screen size: \(screenSize)", className: "CodePositionMarker") - logDebug("Clamped: x=\(clampedX), y=\(clampedY)", className: "CodePositionMarker") - - return CGPoint(x: clampedX, y: clampedY) - } -} - -// MARK: - 重新扫描按钮样式 -struct RescanButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.95 : 1.0) - .opacity(configuration.isPressed ? 0.8 : 1.0) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) - } -} - -// MARK: - 扫描线动画修饰符 -struct ScanningLineModifier: ViewModifier { - @State private var isAnimating = false - - func body(content: Content) -> some View { - content - .offset(y: isAnimating ? 150 : -150) - .onAppear { - withAnimation( - Animation.linear(duration: 2) - .repeatForever(autoreverses: false) - ) { - isAnimating = true - } - } - } -} - -// MARK: - 脉冲动画修饰符 -struct PulseAnimationModifier: ViewModifier { - @State private var isPulsing = false - - func body(content: Content) -> some View { - content - .scaleEffect(isPulsing ? 1.5 : 1.0) - .opacity(isPulsing ? 0.0 : 0.8) - .onAppear { - withAnimation( - Animation.easeInOut(duration: 1.5) - .repeatForever(autoreverses: false) - ) { - isPulsing = true - } - } - } -} - -// MARK: - 扫描线样式枚举 -enum ScanningLineStyle: String, CaseIterable { - case modern = "style_modern" - case classic = "style_classic" - case neon = "style_neon" - case minimal = "style_minimal" - case retro = "style_retro" - - var localizedName: String { - switch self { - case .modern: return "style_modern".localized - case .classic: return "style_classic".localized - case .neon: return "style_neon".localized - case .minimal: return "style_minimal".localized - case .retro: return "style_retro".localized - } - } -} - -// MARK: - 扫描线视图 -struct ScanningLineView: View { - let style: ScanningLineStyle - - var body: some View { - Group { - switch style { - case .modern: - ModernScanningLine() - case .classic: - ClassicScanningLine() - case .neon: - NeonScanningLine() - case .minimal: - MinimalScanningLine() - case .retro: - RetroScanningLine() - } - } - } -} - -// MARK: - 现代扫描线 -struct ModernScanningLine: View { - var body: some View { - Rectangle() - .fill( - LinearGradient( - colors: [.blue, .cyan, .blue], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: 200, height: 3) - .shadow(color: .blue, radius: 5, x: 0, y: 0) - .modifier(ScanningLineModifier()) - } -} - -// MARK: - 经典扫描线 -struct ClassicScanningLine: View { - var body: some View { - Rectangle() - .fill(Color.green) - .frame(width: 150, height: 2) - .modifier(ScanningLineModifier()) - } -} - -// MARK: - 霓虹扫描线 -struct NeonScanningLine: View { - var body: some View { - Rectangle() - .fill(Color.purple) - .frame(width: 180, height: 4) - .shadow(color: .purple, radius: 8, x: 0, y: 0) - .modifier(ScanningLineModifier()) - } -} - -// MARK: - 极简扫描线 -struct MinimalScanningLine: View { - var body: some View { - Rectangle() - .fill(Color.white) - .frame(width: 100, height: 1) - .modifier(ScanningLineModifier()) - } -} - -// MARK: - 复古扫描线 -struct RetroScanningLine: View { - var body: some View { - HStack(spacing: 2) { - ForEach(0..<5, id: \.self) { _ in - Rectangle() - .fill(Color.orange) - .frame(width: 2, height: 20) - } - } - .modifier(ScanningLineModifier()) - } -} - -// MARK: - 检测到的条码数据结构 -struct DetectedCode: Identifiable { - let id = UUID() - let type: String - let content: String - let bounds: CGRect -} - -// MARK: - 通知名称扩展 -extension Notification.Name { - static let scannerDidScanCode = Notification.Name("scannerDidScanCode") -} - -// MARK: - 相机权限视图 -struct CameraPermissionView: View { - let authorizationStatus: AVAuthorizationStatus - let onRequestPermission: () -> Void - let onOpenSettings: () -> Void - - var body: some View { - VStack(spacing: 30) { - Spacer() - - // 相机图标 - Image(systemName: "camera.fill") - .font(.system(size: 80)) - .foregroundColor(.gray) - - // 标题 - Text("camera_permission_title".localized) - .font(.largeTitle) - .fontWeight(.bold) - .multilineTextAlignment(.center) - - // 描述文本 - Text(getDescriptionText()) - .font(.body) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .padding(.horizontal, 40) - - // 操作按钮 - VStack(spacing: 15) { - if authorizationStatus == .notDetermined { - Button(action: onRequestPermission) { - HStack { - Image(systemName: "camera.badge.ellipsis") - Text("request_camera_permission".localized) - } - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .cornerRadius(12) - } - } else if authorizationStatus == .denied || authorizationStatus == .restricted { - Button(action: onOpenSettings) { - HStack { - Image(systemName: "gear") - Text("open_settings".localized) - } - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.orange) - .cornerRadius(12) - } - } - } - .padding(.horizontal, 40) - - Spacer() - } - .background(Color(.systemBackground)) - } - - private func getDescriptionText() -> String { - switch authorizationStatus { - case .notDetermined: - return "camera_permission_description".localized - case .denied: - return "camera_permission_denied".localized - case .restricted: - return "camera_permission_restricted".localized - default: - return "camera_permission_unknown".localized - } - } -} - -#if DEBUG -struct ScannerView_Previews: PreviewProvider { - static var previews: some View { - ScannerView() - } -} -#endif diff --git a/MyQrCode/ScannerView/CameraPermissionView.swift b/MyQrCode/ScannerView/CameraPermissionView.swift new file mode 100644 index 0000000..30b7f45 --- /dev/null +++ b/MyQrCode/ScannerView/CameraPermissionView.swift @@ -0,0 +1,81 @@ +import SwiftUI +import AVFoundation + +// MARK: - 相机权限视图 +struct CameraPermissionView: View { + let authorizationStatus: AVAuthorizationStatus + let onRequestPermission: () -> Void + let onOpenSettings: () -> Void + + var body: some View { + VStack(spacing: 30) { + Spacer() + + // 相机图标 + Image(systemName: "camera.fill") + .font(.system(size: 80)) + .foregroundColor(.gray) + + // 标题 + Text("camera_permission_title".localized) + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + // 描述文本 + Text(getDescriptionText()) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal, 40) + + // 操作按钮 + VStack(spacing: 15) { + if authorizationStatus == .notDetermined { + Button(action: onRequestPermission) { + HStack { + Image(systemName: "camera.badge.ellipsis") + Text("request_camera_permission".localized) + } + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .cornerRadius(12) + } + } else if authorizationStatus == .denied || authorizationStatus == .restricted { + Button(action: onOpenSettings) { + HStack { + Image(systemName: "gear") + Text("open_settings".localized) + } + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.orange) + .cornerRadius(12) + } + } + } + .padding(.horizontal, 40) + + Spacer() + } + .background(Color(.systemBackground)) + } + + private func getDescriptionText() -> String { + switch authorizationStatus { + case .notDetermined: + return "camera_permission_description".localized + case .denied: + return "camera_permission_denied".localized + case .restricted: + return "camera_permission_restricted".localized + default: + return "camera_permission_unknown".localized + } + } +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/CameraPreviewView.swift b/MyQrCode/ScannerView/CameraPreviewView.swift new file mode 100644 index 0000000..8736ca6 --- /dev/null +++ b/MyQrCode/ScannerView/CameraPreviewView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import AVFoundation + +// MARK: - 相机预览视图 +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 + } + } + } +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/CodePositionOverlay.swift b/MyQrCode/ScannerView/CodePositionOverlay.swift new file mode 100644 index 0000000..caa14ac --- /dev/null +++ b/MyQrCode/ScannerView/CodePositionOverlay.swift @@ -0,0 +1,213 @@ +import SwiftUI +import AVFoundation + +// MARK: - 条码位置标记覆盖层 +struct CodePositionOverlay: View { + let detectedCodes: [DetectedCode] + let previewLayer: AVCaptureVideoPreviewLayer? + let onCodeSelected: (DetectedCode) -> Void + let onRescan: () -> Void + + var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(detectedCodes) { code in + CodePositionMarker( + code: code, + screenSize: geometry.size, + previewLayer: previewLayer, + onCodeSelected: onCodeSelected + ) + } + + // 调试信息:显示触摸区域边界 + #if DEBUG + ForEach(detectedCodes) { code in + let position = calculateDebugPosition(code: code, screenSize: geometry.size) + Rectangle() + .stroke(Color.red, lineWidth: 1) + .frame(width: 80, height: 80) + .position(x: position.x, y: position.y) + .opacity(0.3) + } + #endif + + // 重新扫描按钮 - 放在右上角 + VStack { + HStack { + Spacer() + Button(action: { + logInfo("🔄 用户点击重新扫描按钮", className: "CodePositionOverlay") + + // 添加触觉反馈 + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + onRescan() + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 18, weight: .semibold)) + .rotationEffect(.degrees(0)) + .animation(.easeInOut(duration: 0.3), value: true) + + Text("rescan_button".localized) + .font(.system(size: 15, weight: .semibold)) + } + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color.blue.opacity(0.9)) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + ) + } + .buttonStyle(RescanButtonStyle()) + .padding(.trailing, 25) + .padding(.top, 25) + + // 调试按钮:检查会话状态 + #if DEBUG + Button(action: { + logInfo("🔍 调试:检查扫描会话状态", className: "CodePositionOverlay") + // 这里可以添加会话状态检查逻辑 + }) { + Image(systemName: "info.circle") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.yellow) + .padding(8) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + } + .padding(.trailing, 10) + .padding(.top, 25) + #endif + } + Spacer() + } + } + } + .allowsHitTesting(true) + .contentShape(Rectangle()) // 确保整个区域都可以接收触摸事件 + .zIndex(1000) // 确保在最上层,不被其他视图遮挡 + } + + // 调试用的位置计算方法 + private func calculateDebugPosition(code: DetectedCode, screenSize: CGSize) -> CGPoint { + guard let previewLayer = previewLayer else { + return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) + } + + let metadataObject = code.bounds + let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint( + x: metadataObject.midX, + y: metadataObject.midY + )) + + let clampedX = max(40, min(screenSize.width - 40, convertedPoint.x)) + let clampedY = max(40, min(screenSize.height - 40, convertedPoint.y)) + + return CGPoint(x: clampedX, y: clampedY) + } +} + +// MARK: - 单个条码位置标记 +struct CodePositionMarker: View { + let code: DetectedCode + let screenSize: CGSize + let previewLayer: AVCaptureVideoPreviewLayer? + let onCodeSelected: (DetectedCode) -> Void + + var body: some View { + GeometryReader { geometry in + let position = calculatePosition(screenSize: geometry.size) + + ZStack { + // 触摸区域背景(透明,但可以接收触摸事件) + Circle() + .fill(Color.clear) + .frame(width: 80, height: 80) + .contentShape(Circle()) + .onTapGesture { + logDebug("🎯 CodePositionMarker 被点击!", className: "CodePositionMarker") + logDebug(" 条码ID: \(code.id)", className: "CodePositionMarker") + logDebug(" 条码类型: \(code.type)", className: "CodePositionMarker") + logDebug(" 条码内容: \(code.content)", className: "CodePositionMarker") + logDebug(" 点击位置: x=\(position.x), y=\(position.y)", className: "CodePositionMarker") + + // 添加触觉反馈 + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + onCodeSelected(code) + } + + // 外圈 + 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) + .zIndex(1001) // 确保触摸区域在最上层 + .onAppear { + logDebug("CodePositionMarker appeared at: x=\(position.x), y=\(position.y)", className: "CodePositionMarker") + logDebug("Screen size: \(geometry.size)", className: "CodePositionMarker") + logDebug("Code bounds: \(code.bounds)", className: "CodePositionMarker") + } + } + } + + private func calculatePosition(screenSize: CGSize) -> CGPoint { + guard let previewLayer = previewLayer else { + logWarning("No preview layer available, using screen center", className: "CodePositionMarker") + return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) + } + + guard previewLayer.session?.isRunning == true else { + logWarning("Preview layer session not running, using screen center", className: "CodePositionMarker") + return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2) + } + + let metadataObject = code.bounds + let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint( + x: metadataObject.midX, + y: metadataObject.midY + )) + + guard convertedPoint.x.isFinite && convertedPoint.y.isFinite else { + logWarning("Invalid converted point: \(convertedPoint), using screen center", className: "CodePositionMarker") + 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)) + + logDebug("AVFoundation bounds: \(code.bounds)", className: "CodePositionMarker") + logDebug("Converted point: \(convertedPoint)", className: "CodePositionMarker") + logDebug("Screen size: \(screenSize)", className: "CodePositionMarker") + logDebug("Clamped: x=\(clampedX), y=\(clampedY)", className: "CodePositionMarker") + + return CGPoint(x: clampedX, y: clampedY) + } +} + +// MARK: - 重新扫描按钮样式 +struct RescanButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .opacity(configuration.isPressed ? 0.8 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/Models.swift b/MyQrCode/ScannerView/Models.swift new file mode 100644 index 0000000..88f1ede --- /dev/null +++ b/MyQrCode/ScannerView/Models.swift @@ -0,0 +1,15 @@ +import Foundation +import CoreGraphics + +// MARK: - 检测到的条码数据结构 +struct DetectedCode: Identifiable { + let id = UUID() + let type: String + let content: String + let bounds: CGRect +} + +// MARK: - 通知名称扩展 +extension Notification.Name { + static let scannerDidScanCode = Notification.Name("scannerDidScanCode") +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/ScannerView.swift b/MyQrCode/ScannerView/ScannerView.swift new file mode 100644 index 0000000..367db42 --- /dev/null +++ b/MyQrCode/ScannerView/ScannerView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import AVFoundation +import AudioToolbox +import Combine + +// MARK: - 主扫描视图 +struct ScannerView: View { + @StateObject private var scannerViewModel = ScannerViewModel() + @State private var showPreviewPause = false + @State private var selectedScanningStyle: ScanningLineStyle = .modern + @State private var screenOrientation = UIDevice.current.orientation + @State private var previewLayer: AVCaptureVideoPreviewLayer? + + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + // 相机权限检查 + if scannerViewModel.cameraAuthorizationStatus == .authorized { + // 相机预览层 + CameraPreviewView(session: scannerViewModel.captureSession, previewLayer: $previewLayer) + .ignoresSafeArea() + + // 扫描界面覆盖层 + ScanningOverlayView( + showPreviewPause: showPreviewPause, + selectedStyle: $selectedScanningStyle, + detectedCodesCount: scannerViewModel.detectedCodes.count, + onClose: { dismiss() } + ) + + // 条码位置标记覆盖层 + if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty { + CodePositionOverlay( + detectedCodes: scannerViewModel.detectedCodes, + previewLayer: previewLayer, + onCodeSelected: handleCodeSelection, + onRescan: resetToScanning + ) + } + + // 测试按钮(调试用) + if showPreviewPause && scannerViewModel.detectedCodes.count == 1 { + TestAutoSelectButton( + detectedCode: scannerViewModel.detectedCodes[0], + onSelect: handleCodeSelection + ) + } + } else { + // 权限未授权时的UI + CameraPermissionView( + authorizationStatus: scannerViewModel.cameraAuthorizationStatus, + onRequestPermission: { + scannerViewModel.refreshCameraPermission() + }, + onOpenSettings: { + scannerViewModel.openSettings() + } + ) + } + } + .onAppear { + // 只有在相机权限已授权时才启动扫描 + if scannerViewModel.cameraAuthorizationStatus == .authorized { + scannerViewModel.startScanning() + } + } + .onDisappear { + scannerViewModel.stopScanning() + } + .alert("scan_error_title".localized, isPresented: $scannerViewModel.showAlert) { + Button("OK") { } + } message: { + Text("scan_error_message".localized) + } + .onReceive(scannerViewModel.$detectedCodes) { codes in + handleDetectedCodes(codes) + } + .onReceive(scannerViewModel.$cameraAuthorizationStatus) { status in + if status == .authorized { + logInfo("🎯 相机权限已授权,启动扫描", className: "ScannerView") + scannerViewModel.startScanning() + } + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + handleOrientationChange() + } + } + + // MARK: - 私有方法 + + private func handleDetectedCodes(_ codes: [DetectedCode]) { + guard !codes.isEmpty else { return } + + logInfo("检测到条码数量: \(codes.count)", className: "ScannerView") + + if codes.count == 1 { + logInfo("单个条码,准备自动选择", className: "ScannerView") + pauseForPreview() + autoSelectSingleCode(code: codes[0]) + } else { + logInfo("多个条码,等待用户选择", className: "ScannerView") + pauseForPreview() + } + } + + private func handleOrientationChange() { + screenOrientation = UIDevice.current.orientation + logInfo("Screen orientation changed to: \(screenOrientation.rawValue)", className: "ScannerView") + } + + private func handleCodeSelection(_ selectedCode: DetectedCode) { + logInfo("🎯 ScannerView 收到条码选择回调", className: "ScannerView") + logInfo(" 选择的条码ID: \(selectedCode.id)", className: "ScannerView") + logInfo(" 选择的条码类型: \(selectedCode.type)", className: "ScannerView") + logInfo(" 选择的条码内容: \(selectedCode.content)", className: "ScannerView") + logInfo(" 选择的条码位置: \(selectedCode.bounds)", className: "ScannerView") + + let formattedResult = "类型: \(selectedCode.type)\n内容: \(selectedCode.content)" + logInfo(" 格式化结果: \(formattedResult)", className: "ScannerView") + + NotificationCenter.default.post(name: .scannerDidScanCode, object: formattedResult) + dismiss() + } + + private func pauseForPreview() { + showPreviewPause = true + } + + private func resetToScanning() { + logInfo("🔄 ScannerView 开始重置到扫描状态", className: "ScannerView") + + // 重置UI状态 + showPreviewPause = false + + // 重置扫描状态并重新开始 + scannerViewModel.resetDetection() + scannerViewModel.restartScanning() + + // 延迟检查会话状态 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + logInfo("🔍 检查扫描会话状态", className: "ScannerView") + self.scannerViewModel.checkSessionStatus() + } + + logInfo("✅ ScannerView 已重置到扫描状态", className: "ScannerView") + } + + private func autoSelectSingleCode(code: DetectedCode) { + logInfo("开始自动选择定时器,条码类型: \(code.type)", className: "ScannerView") + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + guard self.showPreviewPause && self.scannerViewModel.detectedCodes.count == 1 else { + logInfo("条件不满足,取消自动选择", className: "ScannerView") + return + } + + logInfo("条件满足,执行自动选择", className: "ScannerView") + self.handleCodeSelection(code) + } + } +} + +#if DEBUG +struct ScannerView_Previews: PreviewProvider { + static var previews: some View { + ScannerView() + } +} +#endif \ No newline at end of file diff --git a/MyQrCode/ScannerView/ScannerViewModel.swift b/MyQrCode/ScannerView/ScannerViewModel.swift new file mode 100644 index 0000000..464d481 --- /dev/null +++ b/MyQrCode/ScannerView/ScannerViewModel.swift @@ -0,0 +1,280 @@ +import SwiftUI +import AVFoundation +import AudioToolbox +import Combine + +// MARK: - 扫描器视图模型 +class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + @Published var detectedCodes: [DetectedCode] = [] + @Published var showAlert = false + @Published var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined + @Published var showPermissionAlert = false + + var captureSession: AVCaptureSession! + private var metadataOutput: AVCaptureMetadataOutput? + + override init() { + super.init() + checkCameraPermission() + } + + // MARK: - 相机权限管理 + + private func checkCameraPermission() { + logInfo("🔍 检查相机权限状态", className: "ScannerViewModel") + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + logInfo("✅ 相机权限已授权", className: "ScannerViewModel") + cameraAuthorizationStatus = .authorized + setupCaptureSession() + + case .notDetermined: + logInfo("❓ 相机权限未确定,请求权限", className: "ScannerViewModel") + requestCameraPermission() + + case .denied, .restricted: + logWarning("❌ 相机权限被拒绝或受限", className: "ScannerViewModel") + cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + showPermissionAlert = true + + @unknown default: + logWarning("❓ 未知的相机权限状态", className: "ScannerViewModel") + cameraAuthorizationStatus = .notDetermined + } + } + + private func requestCameraPermission() { + logInfo("🔐 请求相机权限", className: "ScannerViewModel") + + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + if granted { + logInfo("✅ 相机权限请求成功", className: "ScannerViewModel") + self?.cameraAuthorizationStatus = .authorized + self?.setupCaptureSession() + } else { + logWarning("❌ 相机权限请求被拒绝", className: "ScannerViewModel") + self?.cameraAuthorizationStatus = .denied + self?.showPermissionAlert = true + } + } + } + } + + func openSettings() { + logInfo("⚙️ 打开系统设置", className: "ScannerViewModel") + + if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settingsUrl) { success in + if success { + logInfo("✅ 成功打开系统设置", className: "ScannerViewModel") + } else { + logWarning("⚠️ 打开系统设置失败", className: "ScannerViewModel") + } + } + } + } + + func refreshCameraPermission() { + logInfo("🔍 重新检查相机权限状态", className: "ScannerViewModel") + checkCameraPermission() + } + + // MARK: - 相机设置 + + 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 + } + } + + // MARK: - 扫描控制 + + func startScanning() { + logInfo("🔄 开始扫描", className: "ScannerViewModel") + + // 检查会话是否已经在运行 + if captureSession?.isRunning == true { + logInfo("ℹ️ 扫描会话已经在运行", className: "ScannerViewModel") + return + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + self.captureSession?.startRunning() + + // 检查启动状态 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if self.captureSession?.isRunning == true { + logInfo("✅ 扫描会话启动成功", className: "ScannerViewModel") + } else { + logWarning("⚠️ 扫描会话启动失败", className: "ScannerViewModel") + } + } + } + } + + func stopScanning() { + logInfo("🔄 停止扫描", className: "ScannerViewModel") + + // 检查会话是否在运行 + if captureSession?.isRunning == true { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.captureSession?.stopRunning() + + DispatchQueue.main.async { + logInfo("✅ 扫描会话已停止", className: "ScannerViewModel") + } + } + } else { + logInfo("ℹ️ 扫描会话已经停止", className: "ScannerViewModel") + } + } + + func resetDetection() { + DispatchQueue.main.async { + logInfo("🔄 重置检测状态,清空 detectedCodes", className: "ScannerViewModel") + self.detectedCodes = [] + } + } + + func isSessionRunning() -> Bool { + return captureSession?.isRunning == true + } + + func checkSessionStatus() { + let isRunning = captureSession?.isRunning == true + logInfo("📊 扫描会话状态检查: \(isRunning ? "运行中" : "已停止")", className: "ScannerViewModel") + + if !isRunning { + logWarning("⚠️ 扫描会话未运行,尝试重新启动", className: "ScannerViewModel") + startScanning() + } + } + + func restartScanning() { + logInfo("🔄 重新开始扫描", className: "ScannerViewModel") + + // 确保在主线程执行UI相关操作 + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // 先停止当前会话 + if self.captureSession?.isRunning == true { + logInfo("🔄 停止当前运行的扫描会话", className: "ScannerViewModel") + self.captureSession?.stopRunning() + } + + // 重置检测状态 + self.detectedCodes = [] + + // 延迟后重新启动会话 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + + logInfo("🔄 准备重新启动扫描会话", className: "ScannerViewModel") + + // 在后台线程启动会话 + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession?.startRunning() + + // 检查会话状态 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if self.captureSession?.isRunning == true { + logInfo("✅ 扫描会话已成功重新启动", className: "ScannerViewModel") + } else { + logWarning("⚠️ 扫描会话启动失败,尝试重新启动", className: "ScannerViewModel") + // 如果启动失败,再次尝试 + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession?.startRunning() + DispatchQueue.main.async { + if self.captureSession?.isRunning == true { + logInfo("✅ 扫描会话第二次尝试启动成功", className: "ScannerViewModel") + } else { + logError("❌ 扫描会话启动失败", className: "ScannerViewModel") + } + } + } + } + } + } + } + } + } + + // MARK: - AVCaptureMetadataOutputObjectsDelegate + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + + logInfo("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象", className: "ScannerViewModel") + + // 震动反馈 + 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) + logInfo("创建 DetectedCode: 类型=\(codeType), 内容=\(stringValue)", className: "ScannerViewModel") + } + } + + logInfo("准备更新 detectedCodes,数量: \(codes.count)", className: "ScannerViewModel") + + // 更新检测到的条码列表 + DispatchQueue.main.async { + logInfo("在主线程更新 detectedCodes", className: "ScannerViewModel") + self.detectedCodes = codes + } + } +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/ScanningLineView.swift b/MyQrCode/ScannerView/ScanningLineView.swift new file mode 100644 index 0000000..d4e62fd --- /dev/null +++ b/MyQrCode/ScannerView/ScanningLineView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +// MARK: - 扫描线视图 +struct ScanningLineView: View { + let style: ScanningLineStyle + + var body: some View { + Group { + switch style { + case .modern: + ModernScanningLine() + case .classic: + ClassicScanningLine() + case .neon: + NeonScanningLine() + case .minimal: + MinimalScanningLine() + case .retro: + RetroScanningLine() + } + } + } +} + +// MARK: - 扫描线样式枚举 +enum ScanningLineStyle: String, CaseIterable { + case modern = "style_modern" + case classic = "style_classic" + case neon = "style_neon" + case minimal = "style_minimal" + case retro = "style_retro" + + var localizedName: String { + switch self { + case .modern: return "style_modern".localized + case .classic: return "style_classic".localized + case .neon: return "style_neon".localized + case .minimal: return "style_minimal".localized + case .retro: return "style_retro".localized + } + } +} + +// MARK: - 扫描线动画修饰符 +struct ScanningLineModifier: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .offset(y: isAnimating ? 150 : -150) + .onAppear { + withAnimation( + Animation.linear(duration: 2) + .repeatForever(autoreverses: false) + ) { + isAnimating = true + } + } + } +} + +// MARK: - 脉冲动画修饰符 +struct PulseAnimationModifier: ViewModifier { + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .scaleEffect(isPulsing ? 1.5 : 1.0) + .opacity(isPulsing ? 0.0 : 0.8) + .onAppear { + withAnimation( + Animation.easeInOut(duration: 1.5) + .repeatForever(autoreverses: false) + ) { + isPulsing = true + } + } + } +} + +// MARK: - 现代扫描线 +struct ModernScanningLine: View { + var body: some View { + Rectangle() + .fill( + LinearGradient( + colors: [.blue, .cyan, .blue], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 200, height: 3) + .shadow(color: .blue, radius: 5, x: 0, y: 0) + .modifier(ScanningLineModifier()) + } +} + +// MARK: - 经典扫描线 +struct ClassicScanningLine: View { + var body: some View { + Rectangle() + .fill(Color.green) + .frame(width: 150, height: 2) + .modifier(ScanningLineModifier()) + } +} + +// MARK: - 霓虹扫描线 +struct NeonScanningLine: View { + var body: some View { + Rectangle() + .fill(Color.purple) + .frame(width: 180, height: 4) + .shadow(color: .purple, radius: 8, x: 0, y: 0) + .modifier(ScanningLineModifier()) + } +} + +// MARK: - 极简扫描线 +struct MinimalScanningLine: View { + var body: some View { + Rectangle() + .fill(Color.white) + .frame(width: 100, height: 1) + .modifier(ScanningLineModifier()) + } +} + +// MARK: - 复古扫描线 +struct RetroScanningLine: View { + var body: some View { + HStack(spacing: 2) { + ForEach(0..<5, id: \.self) { _ in + Rectangle() + .fill(Color.orange) + .frame(width: 2, height: 20) + } + } + .modifier(ScanningLineModifier()) + } +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/ScanningOverlayView.swift b/MyQrCode/ScannerView/ScanningOverlayView.swift new file mode 100644 index 0000000..15b86df --- /dev/null +++ b/MyQrCode/ScannerView/ScanningOverlayView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +// MARK: - 扫描界面覆盖层 +struct ScanningOverlayView: View { + let showPreviewPause: Bool + @Binding var selectedStyle: ScanningLineStyle + let detectedCodesCount: Int + let onClose: () -> Void + + var body: some View { + VStack { + Spacer() + + // 扫描线组件 + if !showPreviewPause { + ScanningLineView(style: selectedStyle) + } + + // 提示文本 + ScanningInstructionView( + showPreviewPause: showPreviewPause, + detectedCodesCount: detectedCodesCount + ) + + Spacer() + + // 底部按钮区域 + ScanningBottomButtonsView( + showPreviewPause: showPreviewPause, + selectedStyle: $selectedStyle, + onClose: onClose + ) + } + } +} + +// MARK: - 扫描指令视图 +struct ScanningInstructionView: View { + let showPreviewPause: Bool + let detectedCodesCount: Int + + var body: some View { + if showPreviewPause { + VStack(spacing: 8) { + Text("detected_codes".localized) + .foregroundColor(.white) + .font(.headline) + + if detectedCodesCount == 1 { + Text("auto_result_1s".localized) + .foregroundColor(.green) + .font(.subheadline) + } else { + Text("select_code_instruction".localized) + .foregroundColor(.white.opacity(0.8)) + .font(.subheadline) + } + } + .padding(.top, 20) + } else { + Text("scan_instruction".localized) + .foregroundColor(.white) + .font(.headline) + .padding(.top, 20) + } + } +} + +// MARK: - 扫描底部按钮视图 +struct ScanningBottomButtonsView: View { + let showPreviewPause: Bool + @Binding var selectedStyle: ScanningLineStyle + let onClose: () -> Void + + var body: some View { + VStack(spacing: 15) { + // 扫描线样式选择器 + if !showPreviewPause { + ScanningStyleSelectorView(selectedStyle: $selectedStyle) + } + + // 关闭按钮 - 只在非预览选择状态时显示 + if !showPreviewPause { + Button("close_button".localized) { + onClose() + } + .foregroundColor(.white) + .padding() + .background(Color.black.opacity(0.6)) + .cornerRadius(10) + } + } + .padding(.bottom, 50) + } +} + +// MARK: - 扫描线样式选择器 +struct ScanningStyleSelectorView: View { + @Binding var selectedStyle: ScanningLineStyle + + var body: some View { + HStack(spacing: 10) { + ForEach(ScanningLineStyle.allCases, id: \.self) { style in + Button(style.localizedName) { + selectedStyle = style + } + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(selectedStyle == style ? Color.green : Color.gray.opacity(0.6)) + .cornerRadius(8) + .font(.caption) + } + } + .padding(.bottom, 10) + } +} \ No newline at end of file diff --git a/MyQrCode/ScannerView/TestAutoSelectButton.swift b/MyQrCode/ScannerView/TestAutoSelectButton.swift new file mode 100644 index 0000000..1d9bf6d --- /dev/null +++ b/MyQrCode/ScannerView/TestAutoSelectButton.swift @@ -0,0 +1,24 @@ +import SwiftUI + +// MARK: - 测试自动选择按钮 +struct TestAutoSelectButton: View { + let detectedCode: DetectedCode + let onSelect: (DetectedCode) -> Void + + var body: some View { + VStack { + HStack { + Spacer() + Button("test_auto_select".localized) { + onSelect(detectedCode) + } + .foregroundColor(.white) + .padding(8) + .background(Color.red) + .cornerRadius(8) + .padding(.trailing, 20) + } + Spacer() + } + } +} \ No newline at end of file diff --git a/docs/SCANNER_VIEW_REFACTOR_README.md b/docs/SCANNER_VIEW_REFACTOR_README.md new file mode 100644 index 0000000..a590a84 --- /dev/null +++ b/docs/SCANNER_VIEW_REFACTOR_README.md @@ -0,0 +1,203 @@ +# ScannerView 文件分离重构说明 + +## 🎯 重构目标 + +将原本单一的 `ScannerView.swift` 文件中的各个类分离到单独的文件中,并统一放到 `ScannerView/` 文件夹中,提高代码的可维护性和可读性。 + +## 📁 重构后的文件结构 + +``` +MyQrCode/ScannerView/ +├── ScannerView.swift # 主扫描视图 +├── ScannerViewModel.swift # 扫描器视图模型 +├── ScanningOverlayView.swift # 扫描界面覆盖层 +├── ScanningLineView.swift # 扫描线相关视图和样式 +├── CodePositionOverlay.swift # 条码位置标记覆盖层 +├── CameraPreviewView.swift # 相机预览视图 +├── CameraPermissionView.swift # 相机权限视图 +├── TestAutoSelectButton.swift # 测试自动选择按钮 +└── Models.swift # 数据模型和扩展 +``` + +## 🔧 分离的类和组件 + +### 1. **ScannerView.swift** - 主扫描视图 +- **主要职责**: 扫描视图的主要结构和逻辑 +- **包含内容**: + - 主视图结构 + - 权限状态检查 + - 事件处理方法 + - 状态管理 + +### 2. **ScannerViewModel.swift** - 扫描器视图模型 +- **主要职责**: 扫描器的核心业务逻辑 +- **包含内容**: + - 相机权限管理 + - 扫描会话控制 + - 条码检测处理 + - 状态同步 + +### 3. **ScanningOverlayView.swift** - 扫描界面覆盖层 +- **主要职责**: 扫描界面的UI覆盖层 +- **包含内容**: + - `ScanningOverlayView`: 主覆盖层结构 + - `ScanningInstructionView`: 扫描指令显示 + - `ScanningBottomButtonsView`: 底部按钮区域 + - `ScanningStyleSelectorView`: 扫描线样式选择器 + +### 4. **ScanningLineView.swift** - 扫描线相关视图 +- **主要职责**: 扫描线的样式和动画 +- **包含内容**: + - `ScanningLineView`: 扫描线主视图 + - `ScanningLineStyle`: 扫描线样式枚举 + - `ScanningLineModifier`: 扫描线动画修饰符 + - `PulseAnimationModifier`: 脉冲动画修饰符 + - 各种扫描线样式实现(现代、经典、霓虹、极简、复古) + +### 5. **CodePositionOverlay.swift** - 条码位置标记 +- **主要职责**: 条码位置标记和交互 +- **包含内容**: + - `CodePositionOverlay`: 条码位置标记覆盖层 + - `CodePositionMarker`: 单个条码位置标记 + - `RescanButtonStyle`: 重新扫描按钮样式 + +### 6. **CameraPreviewView.swift** - 相机预览视图 +- **主要职责**: 相机预览的UI包装 +- **包含内容**: + - `CameraPreviewView`: UIViewRepresentable 包装器 + +### 7. **CameraPermissionView.swift** - 相机权限视图 +- **主要职责**: 相机权限相关的UI +- **包含内容**: + - `CameraPermissionView`: 权限状态显示和操作 + +### 8. **TestAutoSelectButton.swift** - 测试按钮 +- **主要职责**: 调试用的自动选择测试按钮 +- **包含内容**: + - `TestAutoSelectButton`: 测试按钮视图 + +### 9. **Models.swift** - 数据模型 +- **主要职责**: 数据结构和扩展 +- **包含内容**: + - `DetectedCode`: 检测到的条码数据结构 + - `Notification.Name` 扩展: 通知名称定义 + +## 🚀 重构的优势 + +### 1. **代码组织性** +- 每个文件都有明确的职责 +- 相关的功能被组织在一起 +- 文件大小更加合理 + +### 2. **可维护性** +- 修改特定功能时只需要关注对应文件 +- 减少了文件冲突的可能性 +- 代码更容易理解和调试 + +### 3. **可重用性** +- 各个组件可以独立使用 +- 便于在其他项目中复用 +- 组件间的依赖关系更清晰 + +### 4. **团队协作** +- 不同开发者可以同时修改不同文件 +- 代码审查更加聚焦 +- 减少了合并冲突 + +### 5. **测试友好** +- 可以独立测试各个组件 +- 单元测试更容易编写 +- 测试覆盖率更容易提高 + +## 📋 重构检查清单 + +- ✅ 主扫描视图分离 +- ✅ 视图模型分离 +- ✅ 扫描覆盖层分离 +- ✅ 扫描线相关组件分离 +- ✅ 条码位置标记分离 +- ✅ 相机预览视图分离 +- ✅ 相机权限视图分离 +- ✅ 测试按钮分离 +- ✅ 数据模型分离 +- ✅ 原文件删除 +- ✅ 项目编译通过 + +## 🔍 文件依赖关系 + +``` +ScannerView.swift +├── ScannerViewModel.swift +├── ScanningOverlayView.swift +├── CodePositionOverlay.swift +├── CameraPreviewView.swift +├── CameraPermissionView.swift +└── TestAutoSelectButton.swift + +ScanningOverlayView.swift +├── ScanningLineView.swift +└── Models.swift + +CodePositionOverlay.swift +└── Models.swift + +ScanningLineView.swift +└── Models.swift +``` + +## 🧪 测试建议 + +### 1. **编译测试** +- 确保所有文件都能正确编译 +- 检查是否有缺失的导入语句 +- 验证类型引用是否正确 + +### 2. **功能测试** +- 测试扫描功能是否正常 +- 验证权限管理是否工作 +- 检查UI组件是否正常显示 + +### 3. **性能测试** +- 确保文件分离没有影响性能 +- 检查内存使用是否正常 +- 验证启动时间是否合理 + +## 🚨 注意事项 + +### 1. **导入语句** +- 每个文件都需要正确的 import 语句 +- 确保依赖关系清晰 +- 避免循环依赖 + +### 2. **访问控制** +- 检查 public、internal、private 修饰符 +- 确保组件间的访问权限正确 +- 避免过度暴露内部实现 + +### 3. **文件命名** +- 文件名应该清晰表达其内容 +- 遵循 Swift 命名规范 +- 保持命名的一致性 + +## 🎯 后续优化建议 + +### 1. **进一步模块化** +- 考虑将相关组件打包成独立的模块 +- 使用 Swift Package Manager 管理依赖 +- 创建组件库供其他项目使用 + +### 2. **文档完善** +- 为每个组件添加详细的文档注释 +- 创建使用示例和最佳实践 +- 提供组件使用指南 + +### 3. **测试覆盖** +- 为每个组件编写单元测试 +- 添加集成测试 +- 实现自动化测试流程 + +## 📊 重构总结 + +通过这次重构,我们成功地将原本单一的 `ScannerView.swift` 文件分离成了9个独立的文件,每个文件都有明确的职责和功能。这样的重构大大提高了代码的可维护性、可读性和可重用性,为后续的开发工作奠定了良好的基础。 + +重构后的代码结构更加清晰,组件间的依赖关系更加明确,团队协作效率得到提升。同时,这种模块化的设计也为未来的功能扩展和优化提供了更大的灵活性。 \ No newline at end of file