// // OrderCodeScannerView.swift // suixinkan // // Created by Codex on 2026/6/22. // import AVFoundation import SwiftUI /// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。 enum OrderScannerError: LocalizedError { case cameraUnavailable case permissionDenied case invalidMetadata var errorDescription: String? { switch self { case .cameraUnavailable: return "当前设备不可用相机,无法扫码。" case .permissionDenied: return "未开启相机权限,请在系统设置中允许后重试。" case .invalidMetadata: return "未识别到有效条码,请重试。" } } } /// SwiftUI 扫码页包装,提供标题、关闭按钮和底部扫码提示。 struct OrderScannerPage: View { let onClose: () -> Void let onSuccess: (String) -> Void let onFailure: (Error) -> Void var body: some View { NavigationStack { content .navigationTitle("扫码核销") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { onClose() } .accessibilityIdentifier("scanner.close") } } } } @ViewBuilder private var content: some View { if shouldUseCameraPreview { ZStack(alignment: .bottom) { OrderCodeScannerView { result in switch result { case .success(let raw): onSuccess(raw) case .failure(let error): onFailure(error) } } .ignoresSafeArea() Label("请将二维码/条码放入取景框", systemImage: "qrcode.viewfinder") .font(.system(size: AppMetrics.FontSize.footnote, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, AppMetrics.Spacing.medium) .padding(.vertical, AppMetrics.Spacing.xSmall) .background(.black.opacity(0.55), in: Capsule()) .padding(.bottom, AppMetrics.Spacing.xxLarge) } } else { ScannerUnavailableContent() } } private var shouldUseCameraPreview: Bool { #if targetEnvironment(simulator) return false #else guard !AppUITestLaunchState.isRunningUITests else { return false } return AVCaptureDevice.default(for: .video) != nil #endif } } /// 无可用摄像头时展示的扫码占位内容,避免在模拟器和 UI Test 中启动真实采集会话。 private struct ScannerUnavailableContent: View { var body: some View { VStack(spacing: AppMetrics.Spacing.small) { Image(systemName: "camera.viewfinder") .font(.system(size: 48, weight: .semibold)) .foregroundStyle(.white) Text("当前环境不可用相机") .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .foregroundStyle(.white) Text("请在真机上使用扫码核销") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(.white.opacity(0.72)) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black) } } /// AVFoundation 扫码桥接视图,负责把相机识别结果回传给 SwiftUI。 struct OrderCodeScannerView: UIViewControllerRepresentable { let onScanResult: (Result) -> Void /// 创建底层扫码控制器。 func makeUIViewController(context: Context) -> OrderScannerViewController { let controller = OrderScannerViewController() controller.onScanResult = onScanResult return controller } /// 扫码控制器没有 SwiftUI 驱动的可变配置。 func updateUIViewController(_ uiViewController: OrderScannerViewController, context: Context) {} } /// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。 final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onScanResult: ((Result) -> Void)? private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? private var videoDevice: AVCaptureDevice? private var hasFinishedScan = false private let scanFrameView: UIView = { let view = UIView() view.layer.borderColor = UIColor.systemBlue.cgColor view.layer.borderWidth = 2 view.layer.cornerRadius = 12 view.backgroundColor = .clear view.translatesAutoresizingMaskIntoConstraints = false return view }() private let guideLabel: UILabel = { let label = UILabel() label.text = "将二维码/条形码放入框内自动识别" label.textColor = .white label.font = .systemFont(ofSize: 13, weight: .medium) label.textAlignment = .center label.translatesAutoresizingMaskIntoConstraints = false return label }() private lazy var torchButton: UIButton = { let button = Self.makeScannerButton(title: "手电筒", backgroundColor: UIColor.black.withAlphaComponent(0.42)) button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside) return button }() private lazy var retryButton: UIButton = { let button = Self.makeScannerButton(title: "重新扫描", backgroundColor: UIColor.systemBlue.withAlphaComponent(0.85)) button.addTarget(self, action: #selector(restartScan), for: .touchUpInside) return button }() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupControls() checkPermissionAndStart() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.layer.bounds } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) updateTorch(enabled: false) stopSession() } /// 检查相机权限,并在授权后启动扫码。 private func checkPermissionAndStart() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: startSession() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { guard let self else { return } if granted { self.startSession() } else { self.onScanResult?(.failure(OrderScannerError.permissionDenied)) } } } case .denied, .restricted: onScanResult?(.failure(OrderScannerError.permissionDenied)) @unknown default: onScanResult?(.failure(OrderScannerError.permissionDenied)) } } /// 启动扫码会话。 private func startSession() { hasFinishedScan = false guard !session.isRunning else { return } do { try configureSession() } catch { onScanResult?(.failure(error)) return } DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } } /// 停止扫码会话。 private func stopSession() { guard session.isRunning else { return } session.stopRunning() } /// 创建扫码页底部操作按钮。 private static func makeScannerButton(title: String, backgroundColor: UIColor) -> UIButton { var attributedTitle = AttributedString(title) attributedTitle.font = .systemFont(ofSize: 13, weight: .semibold) var configuration = UIButton.Configuration.filled() configuration.attributedTitle = attributedTitle configuration.baseForegroundColor = .white configuration.baseBackgroundColor = backgroundColor configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) configuration.cornerStyle = .capsule let button = UIButton(type: .system) button.configuration = configuration button.translatesAutoresizingMaskIntoConstraints = false return button } /// 配置相机输入、元数据输出和预览层。 private func configureSession() throws { if previewLayer != nil { return } guard let videoDevice = AVCaptureDevice.default(for: .video) else { throw OrderScannerError.cameraUnavailable } self.videoDevice = videoDevice let videoInput = try AVCaptureDeviceInput(device: videoDevice) guard session.canAddInput(videoInput) else { throw OrderScannerError.cameraUnavailable } session.addInput(videoInput) let metadataOutput = AVCaptureMetadataOutput() guard session.canAddOutput(metadataOutput) else { throw OrderScannerError.invalidMetadata } session.addOutput(metadataOutput) metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) let desiredTypes: [AVMetadataObject.ObjectType] = [ .qr, .ean8, .ean13, .pdf417, .code39, .code93, .code128, .dataMatrix, .aztec, .itf14, .upce ] let availableTypes = Set(metadataOutput.availableMetadataObjectTypes) let supportedTypes = desiredTypes.filter { availableTypes.contains($0) } guard !supportedTypes.isEmpty else { throw OrderScannerError.invalidMetadata } metadataOutput.metadataObjectTypes = supportedTypes let preview = AVCaptureVideoPreviewLayer(session: session) preview.videoGravity = .resizeAspectFill preview.frame = view.layer.bounds view.layer.insertSublayer(preview, at: 0) previewLayer = preview updateTorchButtonState() } /// 处理相机识别到的二维码或条形码内容。 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasFinishedScan else { return } guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let code = metadataObject.stringValue, !code.isEmpty else { return } hasFinishedScan = true stopSession() UINotificationFeedbackGenerator().notificationOccurred(.success) onScanResult?(.success(code)) } /// 布局扫码框、提示文本和操作按钮。 private func setupControls() { view.addSubview(scanFrameView) view.addSubview(guideLabel) view.addSubview(torchButton) view.addSubview(retryButton) NSLayoutConstraint.activate([ scanFrameView.centerXAnchor.constraint(equalTo: view.centerXAnchor), scanFrameView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40), scanFrameView.widthAnchor.constraint(equalToConstant: 240), scanFrameView.heightAnchor.constraint(equalToConstant: 240), guideLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), guideLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), guideLabel.topAnchor.constraint(equalTo: scanFrameView.bottomAnchor, constant: 14), torchButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), torchButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18), retryButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), retryButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18) ]) } /// 根据设备能力更新手电筒按钮状态。 private func updateTorchButtonState() { let hasTorch = videoDevice?.hasTorch == true torchButton.isEnabled = hasTorch torchButton.alpha = hasTorch ? 1 : 0.45 } /// 切换手电筒开关。 @objc private func toggleTorch() { let next = !(videoDevice?.isTorchActive ?? false) updateTorch(enabled: next) } /// 设置手电筒状态。 private func updateTorch(enabled: Bool) { guard let device = videoDevice, device.hasTorch else { return } do { try device.lockForConfiguration() if enabled { try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel) } else { device.torchMode = .off } device.unlockForConfiguration() torchButton.setTitle(enabled ? "关闭手电" : "手电筒", for: .normal) } catch { torchButton.setTitle("手电筒不可用", for: .normal) } } /// 重置扫描状态并重新启动识别。 @objc private func restartScan() { hasFinishedScan = false updateTorch(enabled: false) startSession() } }