Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/OrderCodeScannerView.swift

331 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 {
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)
}
.navigationTitle("扫码核销")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") {
onClose()
}
.accessibilityIdentifier("scanner.close")
}
}
}
}
}
/// 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)
metadataOutput.metadataObjectTypes = [
.qr,
.ean8,
.ean13,
.pdf417,
.code39,
.code93,
.code128,
.dataMatrix,
.aztec,
.itf14,
.upce
]
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()
}
}