Files
ios/Xuxucheck/XuXuCheck/qrcode/ScannerViewController.swift
T
admin d6aeede2bc 导入 XuXuCheck 工程(iOS 干化学尿液试纸分析 App)
从桌面工程复制而来,作为本仓库的 Xuxucheck/ 子目录。

为控制仓库体积做了两处删减:
- 移除 Frameworks/opencv2.xcframework 下的两个 visionOS 切片
  (xros-arm64、xros-arm64-simulator,合计约 106 MB),并同步删掉该
  xcframework 内 Info.plist 里对应的两条 AvailableLibraries 记录,
  只保留 ios-arm64 与 ios-arm64_x86_64-simulator。
- 移除全部 .DS_Store 与 xcuserdata/。

已用 xcodebuild 从本目录完整编译验证:BUILD SUCCEEDED,
ProcessXCFramework 以 --platform ios 成功选中框架,产物含 3699 个 cv:: 符号
(OpenCV 确实链入),嵌入 framework 3 个,与移除无用 pod 之后的状态一致。

仓库约 206 MB,其中 OpenCV 203 MB、其余源码与依赖约 3 MB。
2026-09-23 14:17:31 +08:00

605 lines
22 KiB
Swift

//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
#if os(iOS)
import AVFoundation
import UIKit
@available(macCatalyst 14.0, *)
extension CodeScannerView {
public final class ScannerViewController: UIViewController, UINavigationControllerDelegate {
private let photoOutput = AVCapturePhotoOutput()
private var isCapturing = false
private var handler: ((UIImage?) -> Void)?
var parentView: CodeScannerView!
var codesFound = Set<String>()
var didFinishScanning = false
var lastTime = Date(timeIntervalSince1970: 0)
private let showViewfinder: Bool
let fallbackVideoCaptureDevice = AVCaptureDevice.default(for: .video)
private var isGalleryShowing: Bool = false {
didSet {
// Update binding
if parentView.isGalleryPresented.wrappedValue != isGalleryShowing {
parentView.isGalleryPresented.wrappedValue = isGalleryShowing
}
}
}
public init(showViewfinder: Bool = false, parentView: CodeScannerView) {
self.parentView = parentView
self.showViewfinder = showViewfinder
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
self.showViewfinder = false
super.init(coder: coder)
}
func openGallery() {
isGalleryShowing = true
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
imagePicker.presentationController?.delegate = self
present(imagePicker, animated: true, completion: nil)
}
@objc func openGalleryFromButton(_ sender: UIButton) {
openGallery()
}
#if targetEnvironment(simulator)
override public func loadView() {
view = UIView()
view.isUserInteractionEnabled = true
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data."
label.textAlignment = .center
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Select a custom image", for: .normal)
button.setTitleColor(UIColor.systemBlue, for: .normal)
button.setTitleColor(UIColor.gray, for: .highlighted)
button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 50
stackView.addArrangedSubview(label)
stackView.addArrangedSubview(button)
view.addSubview(stackView)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 50),
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Send back their simulated data, as if it was one of the types they were scanning for
found(ScanResult(
string: parentView.simulatedData,
type: parentView.codeTypes.first ?? .qr, image: nil, corners: []
))
}
#else
var captureSession: AVCaptureSession?
var previewLayer: AVCaptureVideoPreviewLayer!
private lazy var viewFinder: UIImageView? = {
guard let image = UIImage(systemName: "viewfinder.circle") else {
return nil
}
let imageView = UIImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private lazy var manualCaptureButton: UIButton = {
let button = UIButton(type: .system)
let image = UIImage(systemName: "hand.point.up.left")
button.setBackgroundImage(image, for: .normal)
button.addTarget(self, action: #selector(manualCapturePressed), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private lazy var manualSelectButton: UIButton = {
let button = UIButton(type: .system)
let image = UIImage(systemName: "photo.on.rectangle")
let background = UIImage(systemName: "capsule.fill")?.withTintColor(.systemBackground, renderingMode: .alwaysOriginal)
button.setImage(image, for: .normal)
button.setBackgroundImage(background, for: .normal)
button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override public func viewDidLoad() {
super.viewDidLoad()
self.addOrientationDidChangeObserver()
self.setBackgroundColor()
self.handleCameraPermission()
}
override public func viewWillLayoutSubviews() {
previewLayer?.frame = view.layer.bounds
}
@objc func updateOrientation() {
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
guard let connection = captureSession?.connections.last, connection.isVideoOrientationSupported else { return }
switch orientation {
case .portrait:
connection.videoOrientation = .portrait
case .landscapeLeft:
connection.videoOrientation = .landscapeLeft
case .landscapeRight:
connection.videoOrientation = .landscapeRight
case .portraitUpsideDown:
connection.videoOrientation = .portraitUpsideDown
default:
connection.videoOrientation = .portrait
}
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateOrientation()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setupSession()
}
private func setupSession() {
guard let captureSession else {
return
}
if previewLayer == nil {
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
}
previewLayer.frame = view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
addViewFinder()
reset()
if !captureSession.isRunning {
DispatchQueue.global(qos: .userInteractive).async {
self.captureSession?.startRunning()
}
}
}
private func handleCameraPermission() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .restricted:
break
case .denied:
self.didFail(reason: .permissionDenied)
case .notDetermined:
self.requestCameraAccess {
self.setupCaptureDevice()
DispatchQueue.main.async {
self.setupSession()
}
}
case .authorized:
self.setupCaptureDevice()
self.setupSession()
default:
break
}
}
private func requestCameraAccess(completion: (() -> Void)?) {
AVCaptureDevice.requestAccess(for: .video) { [weak self] status in
guard status else {
self?.didFail(reason: .permissionDenied)
return
}
completion?()
}
}
private func addOrientationDidChangeObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(updateOrientation),
name: UIDevice.orientationDidChangeNotification,
object: nil
)
}
private func setBackgroundColor(_ color: UIColor = .black) {
view.backgroundColor = color
}
private func setupCaptureDevice() {
captureSession = AVCaptureSession()
guard let videoCaptureDevice = parentView.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
didFail(reason: .initError(error))
return
}
if captureSession!.canAddInput(videoInput) {
captureSession!.addInput(videoInput)
} else {
didFail(reason: .badInput)
return
}
let metadataOutput = AVCaptureMetadataOutput()
if captureSession!.canAddOutput(metadataOutput) {
captureSession!.addOutput(metadataOutput)
captureSession!.addOutput(photoOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = parentView.codeTypes
} else {
didFail(reason: .badOutput)
return
}
}
private func addViewFinder() {
guard showViewfinder, let imageView = viewFinder else { return }
view.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
imageView.widthAnchor.constraint(equalToConstant: 200),
imageView.heightAnchor.constraint(equalToConstant: 200),
])
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if captureSession?.isRunning == true {
DispatchQueue.global(qos: .userInteractive).async {
self.captureSession?.stopRunning()
}
}
NotificationCenter.default.removeObserver(self)
}
override public var prefersStatusBarHidden: Bool {
true
}
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.all
}
/** Touch the screen for autofocus */
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.first?.view == view,
let touchPoint = touches.first,
let device = parentView.videoCaptureDevice ?? fallbackVideoCaptureDevice,
device.isFocusPointOfInterestSupported
else { return }
let videoView = view
let screenSize = videoView!.bounds.size
let xPoint = touchPoint.location(in: videoView).y / screenSize.height
let yPoint = 1.0 - touchPoint.location(in: videoView).x / screenSize.width
let focusPoint = CGPoint(x: xPoint, y: yPoint)
do {
try device.lockForConfiguration()
} catch {
return
}
// Focus to the correct point, make continuous focus and exposure so the point stays sharp when moving the device closer
device.focusPointOfInterest = focusPoint
device.focusMode = .continuousAutoFocus
device.exposurePointOfInterest = focusPoint
device.exposureMode = AVCaptureDevice.ExposureMode.continuousAutoExposure
device.unlockForConfiguration()
}
@objc func manualCapturePressed(_ sender: Any?) {
self.readyManualCapture()
}
func showManualCaptureButton(_ isManualCapture: Bool) {
if manualCaptureButton.superview == nil {
view.addSubview(manualCaptureButton)
NSLayoutConstraint.activate([
manualCaptureButton.heightAnchor.constraint(equalToConstant: 60),
manualCaptureButton.widthAnchor.constraint(equalTo: manualCaptureButton.heightAnchor),
view.centerXAnchor.constraint(equalTo: manualCaptureButton.centerXAnchor),
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: manualCaptureButton.bottomAnchor, constant: 32)
])
}
view.bringSubviewToFront(manualCaptureButton)
manualCaptureButton.isHidden = !isManualCapture
}
func showManualSelectButton(_ isManualSelect: Bool) {
if manualSelectButton.superview == nil {
view.addSubview(manualSelectButton)
NSLayoutConstraint.activate([
manualSelectButton.heightAnchor.constraint(equalToConstant: 50),
manualSelectButton.widthAnchor.constraint(equalToConstant: 60),
view.centerXAnchor.constraint(equalTo: manualSelectButton.centerXAnchor),
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: manualSelectButton.bottomAnchor, constant: 32)
])
}
view.bringSubviewToFront(manualSelectButton)
manualSelectButton.isHidden = !isManualSelect
}
#endif
func updateViewController(isTorchOn: Bool, isGalleryPresented: Bool, isManualCapture: Bool, isManualSelect: Bool) {
guard let videoCaptureDevice = parentView.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
return
}
if videoCaptureDevice.hasTorch {
try? videoCaptureDevice.lockForConfiguration()
videoCaptureDevice.torchMode = isTorchOn ? .on : .off
videoCaptureDevice.unlockForConfiguration()
}
if isGalleryPresented, !isGalleryShowing {
openGallery()
}
#if !targetEnvironment(simulator)
showManualCaptureButton(isManualCapture)
showManualSelectButton(isManualSelect)
#endif
}
public func reset() {
codesFound.removeAll()
didFinishScanning = false
lastTime = Date(timeIntervalSince1970: 0)
}
public func readyManualCapture() {
guard parentView.scanMode.isManual else { return }
self.reset()
lastTime = Date()
}
var isPastScanInterval: Bool {
Date().timeIntervalSince(lastTime) >= parentView.scanInterval
}
var isWithinManualCaptureInterval: Bool {
Date().timeIntervalSince(lastTime) <= 0.5
}
func found(_ result: ScanResult) {
lastTime = Date()
if parentView.shouldVibrateOnSuccess {
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
}
parentView.completion(.success(result))
}
func didFail(reason: ScanError) {
DispatchQueue.main.async {
self.parentView.completion(.failure(reason))
}
}
}
}
// MARK: - AVCaptureMetadataOutputObjectsDelegate
@available(macCatalyst 14.0, *)
extension CodeScannerView.ScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let metadataObject = metadataObjects.first,
!parentView.isPaused,
!didFinishScanning,
!isCapturing,
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue else {
return
}
handler = { [weak self] image in
guard let self else { return }
let result = ScanResult(string: stringValue, type: readableObject.type, image: image, corners: readableObject.corners)
switch parentView.scanMode {
case .once:
found(result)
// make sure we only trigger scan once per use
didFinishScanning = true
case .manual:
if !didFinishScanning, isWithinManualCaptureInterval {
found(result)
didFinishScanning = true
}
case .oncePerCode:
if !codesFound.contains(stringValue) {
codesFound.insert(stringValue)
found(result)
}
case .continuous:
if isPastScanInterval {
found(result)
}
case .continuousExcept(let ignoredList):
if isPastScanInterval, !ignoredList.contains(stringValue) {
found(result)
}
}
}
if parentView.requiresPhotoOutput {
isCapturing = true
photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
} else {
handler?(nil)
}
}
}
// MARK: - UIImagePickerControllerDelegate
@available(macCatalyst 14.0, *)
extension CodeScannerView.ScannerViewController: UIImagePickerControllerDelegate {
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
isGalleryShowing = false
defer {
dismiss(animated: true)
}
guard let qrcodeImg = info[.originalImage] as? UIImage,
let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]),
let ciImage = CIImage(image:qrcodeImg) else {
return
}
let features = detector.features(in: ciImage)
guard !features.isEmpty else {
didFail(reason: .badOutput)
return
}
for feature in features.compactMap({ $0 as? CIQRCodeFeature }) {
guard let qrCodeLink = feature.messageString, !qrCodeLink.isEmpty else {
didFail(reason: .badOutput)
continue
}
let corners = [
feature.bottomLeft,
feature.bottomRight,
feature.topRight,
feature.topLeft
]
let result = ScanResult(string: qrCodeLink, type: .qr, image: qrcodeImg, corners: corners)
found(result)
}
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
isGalleryShowing = false
dismiss(animated: true)
}
}
// MARK: - UIAdaptivePresentationControllerDelegate
@available(macCatalyst 14.0, *)
extension CodeScannerView.ScannerViewController: UIAdaptivePresentationControllerDelegate {
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
// Gallery is no longer being presented
isGalleryShowing = false
}
}
// MARK: - AVCapturePhotoCaptureDelegate
@available(macCatalyst 14.0, *)
extension CodeScannerView.ScannerViewController: AVCapturePhotoCaptureDelegate {
public func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
isCapturing = false
guard let imageData = photo.fileDataRepresentation() else {
print("Error while generating image from photo capture data.");
return
}
guard let qrImage = UIImage(data: imageData) else {
print("Unable to generate UIImage from image data.");
return
}
handler?(qrImage)
}
public func photoOutput(
_ output: AVCapturePhotoOutput,
willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings
) {
AudioServicesDisposeSystemSoundID(1108)
}
public func photoOutput(
_ output: AVCapturePhotoOutput,
didCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings
) {
AudioServicesDisposeSystemSoundID(1108)
}
}
// MARK: - AVCaptureDevice
@available(macCatalyst 14.0, *)
public extension AVCaptureDevice {
/// This returns the Ultra Wide Camera on capable devices and the default Camera for Video otherwise.
static var bestForVideo: AVCaptureDevice? {
let deviceHasUltraWideCamera = !AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInUltraWideCamera], mediaType: .video, position: .back).devices.isEmpty
return deviceHasUltraWideCamera ? AVCaptureDevice.default(.builtInUltraWideCamera, for: .video, position: .back) : AVCaptureDevice.default(for: .video)
}
}
#endif