Files
suixinkan_ios_uikit/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift
2026-06-26 14:33:31 +08:00

302 lines
11 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.

//
// OrderCodeScannerViewController.swift
// suixinkan
//
import AVFoundation
import SnapKit
import UIKit
///
enum OrderScannerError: LocalizedError {
case cameraUnavailable
case permissionDenied
case invalidMetadata
var errorDescription: String? {
switch self {
case .cameraUnavailable:
"当前设备不可用相机,无法扫码。"
case .permissionDenied:
"未开启相机权限,请在系统设置中允许后重试。"
case .invalidMetadata:
"未识别到有效条码,请重试。"
}
}
}
/// AVFoundation
final class OrderCodeScannerViewController: UIViewController {
var onScanResult: ((Result<String, Error>) -> Void)?
private let scannerController = OrderScannerCaptureViewController()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
title = "扫码核销"
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
#if targetEnvironment(simulator)
showUnavailablePlaceholder()
#else
if AppUITestLaunchState.isRunningUITests || AVCaptureDevice.default(for: .video) == nil {
showUnavailablePlaceholder()
} else {
embedScanner()
}
#endif
}
private func embedScanner() {
scannerController.onScanResult = { [weak self] result in
self?.onScanResult?(result)
}
addChild(scannerController)
view.addSubview(scannerController.view)
scannerController.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
scannerController.didMove(toParent: self)
let hintLabel = UILabel()
hintLabel.text = "请将二维码/条码放入取景框"
hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote, weight: .medium)
hintLabel.textColor = .white
hintLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55)
hintLabel.layer.cornerRadius = 16
hintLabel.clipsToBounds = true
hintLabel.textAlignment = .center
view.addSubview(hintLabel)
hintLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.xxLarge)
make.height.equalTo(32)
make.width.greaterThanOrEqualTo(220)
}
}
private func showUnavailablePlaceholder() {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.small
stack.alignment = .center
let icon = UIImageView(image: UIImage(systemName: "camera.viewfinder"))
icon.tintColor = .white
icon.contentMode = .scaleAspectFit
let titleLabel = UILabel()
titleLabel.text = "当前环境不可用相机"
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = .white
let messageLabel = UILabel()
messageLabel.text = "请在真机上使用扫码核销"
messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
messageLabel.textColor = UIColor.white.withAlphaComponent(0.72)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(messageLabel)
view.addSubview(stack)
icon.snp.makeConstraints { make in
make.width.height.equalTo(48)
}
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
@objc private func closeTapped() {
dismiss(animated: true)
}
}
/// AVFoundation
final class OrderScannerCaptureViewController: 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
return view
}()
private lazy var torchButton: UIButton = {
let button = Self.makeScannerButton(title: "手电筒")
button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
return button
}()
private lazy var retryButton: UIButton = {
let button = Self.makeScannerButton(title: "重新扫描")
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) -> UIButton {
var config = UIButton.Configuration.filled()
config.title = title
config.baseForegroundColor = .white
config.baseBackgroundColor = UIColor.black.withAlphaComponent(0.42)
config.cornerStyle = .capsule
return UIButton(configuration: config)
}
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: .main)
let desiredTypes: [AVMetadataObject.ObjectType] = [.qr, .ean8, .ean13, .pdf417, .code39, .code93, .code128, .dataMatrix, .aztec, .itf14, .upce]
let supportedTypes = desiredTypes.filter { metadataOutput.availableMetadataObjectTypes.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
torchButton.isEnabled = videoDevice.hasTorch
}
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(torchButton)
view.addSubview(retryButton)
scanFrameView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-40)
make.width.height.equalTo(240)
}
torchButton.snp.makeConstraints { make in
make.leading.equalTo(view.safeAreaLayoutGuide).inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18)
}
retryButton.snp.makeConstraints { make in
make.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18)
}
}
@objc private func toggleTorch() {
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
}
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.configuration?.title = enabled ? "关闭手电" : "手电筒"
} catch {
torchButton.configuration?.title = "手电筒不可用"
}
}
@objc private func restartScan() {
hasFinishedScan = false
updateTorch(enabled: false)
startSession()
}
}