Files
suixinkan_uikit/suixinkan/UI/Scan/QRCodeScannerViewController.swift
T
lujiuyin 3823d10423 fix: 修复扫码相机竞态并调整金额与浅色外观
串行处理相机配置及启停,补充四项生命周期回归测试。快捷金额去除多余小数,固定浅色模式,版本更新至 2.1.1(2010101)。
2026-09-18 09:37:00 +08:00

197 lines
6.4 KiB
Swift

//
// QRCodeScannerViewController.swift
// suixinkan
//
import AVFoundation
import SnapKit
import UIKit
/// AVFoundation 二维码扫描页。
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
/// 识别到有效二维码后返回内容,每次展示仅回调一次。
var onScanResult: ((String) -> Void)?
private let captureSession = AVCaptureSession()
private lazy var captureController = QRCodeCaptureSessionController(session: captureSession)
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isCameraConfigured = false
private var isCameraConfiguring = false
private var isScannerVisible = false
private var hasHandledResult = false
private let maskView = UIView()
private let hintLabel = UILabel()
private let closeButton = UIButton(type: .system)
override func setupNavigationBar() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func setupUI() {
view.backgroundColor = .black
hintLabel.text = "将二维码放入框内"
hintLabel.textColor = .white
hintLabel.font = .systemFont(ofSize: 14)
hintLabel.textAlignment = .center
closeButton.setTitle("关闭", for: .normal)
closeButton.setTitleColor(.white, for: .normal)
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
view.addSubview(maskView)
view.addSubview(hintLabel)
view.addSubview(closeButton)
}
override func setupConstraints() {
closeButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.trailing.equalToSuperview().inset(16)
}
hintLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(48)
}
maskView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(260)
}
}
override func viewDidLoad() {
super.viewDidLoad()
checkCameraPermission()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isScannerVisible = true
updatePreviewLayout()
startCaptureSessionIfNeeded()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updatePreviewLayout()
updateMask()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isScannerVisible = false
stopCaptureSession()
}
private func checkCameraPermission() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setupCamera()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
granted ? self?.setupCamera() : self?.showPermissionDenied()
}
}
default:
showPermissionDenied()
}
}
private func showPermissionDenied() {
showToast("需要相机权限才能扫码")
dismiss(animated: true)
}
private func setupCamera() {
if isCameraConfigured {
updatePreviewLayout()
startCaptureSessionIfNeeded()
return
}
guard !isCameraConfiguring else { return }
isCameraConfiguring = true
captureController.configure({ [weak self, captureSession] in
guard let self,
let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
captureSession.canAddInput(input) else { return false }
captureSession.addInput(input)
let output = AVCaptureMetadataOutput()
guard captureSession.canAddOutput(output) else {
captureSession.removeInput(input)
return false
}
captureSession.addOutput(output)
guard output.availableMetadataObjectTypes.contains(.qr) else {
captureSession.removeOutput(output)
captureSession.removeInput(input)
return false
}
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
return true
}, completion: { [weak self] succeeded in
guard let self else { return }
self.isCameraConfiguring = false
guard succeeded else {
self.showToast("无法打开相机")
return
}
let layer = AVCaptureVideoPreviewLayer(session: self.captureSession)
layer.videoGravity = .resizeAspectFill
self.view.layer.insertSublayer(layer, at: 0)
self.previewLayer = layer
self.isCameraConfigured = true
self.updatePreviewLayout()
self.startCaptureSessionIfNeeded()
})
}
private func updatePreviewLayout() {
previewLayer?.frame = view.bounds
}
private func startCaptureSessionIfNeeded() {
guard isScannerVisible, isCameraConfigured, !hasHandledResult else { return }
captureController.setRunning(true)
}
private func stopCaptureSession() {
captureController.setRunning(false)
}
private func updateMask() {
maskView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
let border = CAShapeLayer()
border.path = UIBezierPath(roundedRect: maskView.bounds, cornerRadius: 12).cgPath
border.strokeColor = AppColor.primary.cgColor
border.fillColor = UIColor.clear.cgColor
border.lineWidth = 2
maskView.layer.addSublayer(border)
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard !hasHandledResult,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
object.type == .qr,
let value = object.stringValue,
!value.isEmpty else { return }
hasHandledResult = true
stopCaptureSession()
onScanResult?(value)
dismiss(animated: true)
}
@objc private func closeTapped() {
dismiss(animated: true)
}
}