Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,301 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user