预初始化各 Tab 路由容器,修正 iOS 17 ContentUnavailableView 调用,提升自定义 TabBar 与扫码流程的 UI Test 稳定性。 Co-authored-by: Cursor <cursoragent@cursor.com>
376 lines
13 KiB
Swift
376 lines
13 KiB
Swift
//
|
||
// 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<String, Error>) -> 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<String, Error>) -> 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()
|
||
}
|
||
}
|