You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

691 lines
24 KiB

4 months ago
import SwiftUI
import AVFoundation
import UIKit
import Combine
import AudioToolbox
4 months ago
//
extension Notification.Name {
static let scannerDidScanCode = Notification.Name("scannerDidScanCode")
}
//
struct DetectedCode: Identifiable {
let id = UUID()
let type: String
let content: String
let bounds: CGRect
}
struct ScannerView: View {
@StateObject private var scannerViewModel = ScannerViewModel()
@Environment(\.dismiss) private var dismiss
@State private var showPreviewPause = false
@State private var previewLayer: AVCaptureVideoPreviewLayer?
@State private var screenOrientation = UIDevice.current.orientation
@State private var selectedScanningStyle: ScanningLineStyle = .modern
4 months ago
var body: some View {
ZStack {
//
CameraPreviewView(session: scannerViewModel.captureSession, previewLayer: $previewLayer)
.ignoresSafeArea()
//
VStack {
Spacer()
// 线
if !showPreviewPause {
ScanningLineView(style: selectedScanningStyle)
4 months ago
}
//
4 months ago
if showPreviewPause {
VStack(spacing: 8) {
Text("detected_codes".localized)
4 months ago
.foregroundColor(.white)
.font(.headline)
if scannerViewModel.detectedCodes.count == 1 {
Text("auto_result_1s".localized)
4 months ago
.foregroundColor(.green)
.font(.subheadline)
} else {
Text("select_code_instruction".localized)
4 months ago
.foregroundColor(.white.opacity(0.8))
.font(.subheadline)
}
}
.padding(.top, 20)
} else {
Text("scan_instruction".localized)
.foregroundColor(.white)
.font(.headline)
.padding(.top, 20)
4 months ago
}
Spacer()
//
VStack(spacing: 15) {
// 线
if !showPreviewPause {
HStack(spacing: 10) {
ForEach(ScanningLineStyle.allCases, id: \.self) { style in
Button(style.localizedName) {
selectedScanningStyle = style
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(selectedScanningStyle == style ? Color.green : Color.gray.opacity(0.6))
.cornerRadius(8)
.font(.caption)
}
}
.padding(.bottom, 10)
}
4 months ago
if showPreviewPause {
//
Button("rescan_button".localized) {
4 months ago
resetToScanning()
}
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.blue)
.cornerRadius(20)
}
//
Button("close_button".localized) {
4 months ago
dismiss()
}
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.6))
.cornerRadius(10)
}
.padding(.bottom, 50)
}
//
if showPreviewPause && !scannerViewModel.detectedCodes.isEmpty {
CodePositionOverlay(
detectedCodes: scannerViewModel.detectedCodes,
previewLayer: previewLayer,
onCodeSelected: { selectedCode in
NotificationCenter.default.post(name: .scannerDidScanCode, object: selectedCode)
dismiss()
}
)
}
//
if showPreviewPause && scannerViewModel.detectedCodes.count == 1 {
VStack {
HStack {
Spacer()
Button("test_auto_select".localized) {
let code = scannerViewModel.detectedCodes[0]
let selectedCode = "类型: \(code.type)\n内容: \(code.content)"
NotificationCenter.default.post(name: .scannerDidScanCode, object: selectedCode)
dismiss()
}
.foregroundColor(.white)
.padding(8)
.background(Color.red)
.cornerRadius(8)
.padding(.trailing, 20)
4 months ago
}
Spacer()
}
}
}
.onAppear {
scannerViewModel.startScanning()
}
.onDisappear {
scannerViewModel.stopScanning()
}
.alert("scan_error_title".localized, isPresented: $scannerViewModel.showAlert) {
4 months ago
Button("确定") {
dismiss()
}
} message: {
Text("scan_error_message".localized)
4 months ago
}
.onReceive(scannerViewModel.$detectedCodes) { codes in
if !codes.isEmpty {
logInfo("检测到条码数量: \(codes.count)", className: "ScannerView")
4 months ago
if codes.count == 1 {
//
logInfo("单个条码,准备自动选择", className: "ScannerView")
4 months ago
pauseForPreview()
autoSelectSingleCode(code: codes[0])
} else {
//
logInfo("多个条码,等待用户选择", className: "ScannerView")
4 months ago
pauseForPreview()
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
//
screenOrientation = UIDevice.current.orientation
logInfo("Screen orientation changed to: \(screenOrientation)", className: "ScannerView")
4 months ago
}
}
private func pauseForPreview() {
showPreviewPause = true
//
}
private func resetToScanning() {
showPreviewPause = false
scannerViewModel.resetDetection()
scannerViewModel.startScanning()
}
private func autoSelectSingleCode(code: DetectedCode) {
logInfo("开始自动选择定时器,条码类型: \(code.type)", className: "ScannerView")
4 months ago
//
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
logInfo("自动选择定时器触发", className: "ScannerView")
logInfo("当前状态 - showPreviewPause: \(self.showPreviewPause)", className: "ScannerView")
logInfo("当前条码数量: \(self.scannerViewModel.detectedCodes.count)", className: "ScannerView")
4 months ago
if self.showPreviewPause && self.scannerViewModel.detectedCodes.count == 1 {
logInfo("条件满足,执行自动选择", className: "ScannerView")
4 months ago
//
let selectedCode = "类型: \(code.type)\n内容: \(code.content)"
logInfo("发送通知: \(selectedCode)", className: "ScannerView")
4 months ago
NotificationCenter.default.post(name: .scannerDidScanCode, object: selectedCode)
self.dismiss()
} else {
logInfo("条件不满足,取消自动选择", className: "ScannerView")
4 months ago
}
}
}
}
//
struct CameraPreviewView: UIViewRepresentable {
let session: AVCaptureSession
@Binding var previewLayer: AVCaptureVideoPreviewLayer?
func makeUIView(context: Context) -> UIView {
let view = UIView()
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = view.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
//
DispatchQueue.main.async {
self.previewLayer = previewLayer
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if let previewLayer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
previewLayer.frame = uiView.bounds
//
DispatchQueue.main.async {
self.previewLayer = previewLayer
}
}
}
}
//
class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
@Published var detectedCodes: [DetectedCode] = []
@Published var showAlert = false
var captureSession: AVCaptureSession!
private var metadataOutput: AVCaptureMetadataOutput?
override init() {
super.init()
setupCaptureSession()
}
// MARK: -
4 months ago
private func setupCaptureSession() {
captureSession = AVCaptureSession()
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
showAlert = true
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
showAlert = true
return
}
if captureSession.canAddInput(videoInput) {
captureSession.addInput(videoInput)
} else {
showAlert = true
return
}
metadataOutput = AVCaptureMetadataOutput()
if let metadataOutput = metadataOutput,
captureSession.canAddOutput(metadataOutput) {
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [.qr, .ean8, .ean13, .code128, .code39, .upce, .pdf417, .aztec]
} else {
showAlert = true
return
}
}
func startScanning() {
DispatchQueue.global(qos: .background).async { [weak self] in
self?.captureSession?.startRunning()
}
}
func stopScanning() {
DispatchQueue.global(qos: .background).async { [weak self] in
self?.captureSession?.stopRunning()
}
}
func resetDetection() {
DispatchQueue.main.async {
self.detectedCodes = []
}
}
// MARK: - AVCaptureMetadataOutputObjectsDelegate
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
logInfo("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象", className: "ScannerViewModel")
4 months ago
//
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
//
stopScanning()
//
var codes: [DetectedCode] = []
for metadataObject in metadataObjects {
if let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue {
let codeType = readableObject.type.rawValue
let bounds = readableObject.bounds
let detectedCode = DetectedCode(
type: codeType,
content: stringValue,
bounds: bounds
)
codes.append(detectedCode)
logInfo("创建 DetectedCode: 类型=\(codeType), 内容=\(stringValue)", className: "ScannerViewModel")
4 months ago
}
}
logInfo("准备更新 detectedCodes数量: \(codes.count)", className: "ScannerViewModel")
4 months ago
//
DispatchQueue.main.async {
logInfo("在主线程更新 detectedCodes", className: "ScannerViewModel")
4 months ago
self.detectedCodes = codes
}
}
}
//
struct CodePositionOverlay: View {
let detectedCodes: [DetectedCode]
let previewLayer: AVCaptureVideoPreviewLayer?
let onCodeSelected: (String) -> Void
var body: some View {
GeometryReader { geometry in
ZStack {
ForEach(detectedCodes) { code in
CodePositionMarker(
code: code,
screenSize: geometry.size,
previewLayer: previewLayer,
onCodeSelected: onCodeSelected
)
}
}
}
.allowsHitTesting(true) //
}
}
//
struct CodePositionMarker: View {
let code: DetectedCode
let screenSize: CGSize
let previewLayer: AVCaptureVideoPreviewLayer?
let onCodeSelected: (String) -> Void
var body: some View {
GeometryReader { geometry in
// 使GeometryReader
let position = calculatePosition(screenSize: geometry.size)
ZStack {
//
Circle()
.stroke(Color.green, lineWidth: 3)
.frame(width: 40, height: 40)
//
Circle()
.fill(Color.green.opacity(0.3))
.frame(width: 20, height: 20)
//
Circle()
.fill(Color.green)
.frame(width: 6, height: 6)
}
.position(x: position.x, y: position.y)
.background(
//
Circle()
.fill(Color.clear)
.frame(width: 60, height: 60)
)
.onTapGesture {
//
let selectedCode = "类型: \(code.type)\n内容: \(code.content)"
onCodeSelected(selectedCode)
}
.onAppear {
logDebug("CodePositionMarker appeared at: x=\(position.x), y=\(position.y)", className: "CodePositionMarker")
logDebug("Screen size: \(geometry.size)", className: "CodePositionMarker")
logDebug("Code bounds: \(code.bounds)", className: "CodePositionMarker")
4 months ago
}
}
}
private func calculatePosition(screenSize: CGSize) -> CGPoint {
guard let previewLayer = previewLayer else {
// 使
logWarning("No preview layer available, using screen center", className: "CodePositionMarker")
4 months ago
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
}
//
guard previewLayer.session?.isRunning == true else {
logWarning("Preview layer session not running, using screen center", className: "CodePositionMarker")
4 months ago
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
}
// 使AVFoundation
let metadataObject = code.bounds
let convertedPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: CGPoint(
x: metadataObject.midX,
y: metadataObject.midY
))
//
guard convertedPoint.x.isFinite && convertedPoint.y.isFinite else {
logWarning("Invalid converted point: \(convertedPoint), using screen center", className: "CodePositionMarker")
4 months ago
return CGPoint(x: screenSize.width / 2, y: screenSize.height / 2)
}
//
let clampedX = max(20, min(screenSize.width - 20, convertedPoint.x))
let clampedY = max(20, min(screenSize.height - 20, convertedPoint.y))
logDebug("AVFoundation bounds: \(code.bounds)", className: "CodePositionMarker")
logDebug("Converted point: \(convertedPoint)", className: "CodePositionMarker")
logDebug("Screen size: \(screenSize)", className: "CodePositionMarker")
logDebug("Clamped: x=\(clampedX), y=\(clampedY)", className: "CodePositionMarker")
4 months ago
return CGPoint(x: clampedX, y: clampedY)
}
}
// 线
struct ScanningLineModifier: ViewModifier {
@State private var isAnimating = false
func body(content: Content) -> some View {
content
.offset(y: isAnimating ? 150 : -150)
.onAppear {
withAnimation(
Animation.linear(duration: 2)
.repeatForever(autoreverses: false)
) {
isAnimating = true
}
}
}
}
//
struct PulseAnimationModifier: ViewModifier {
@State private var isPulsing = false
func body(content: Content) -> some View {
content
.scaleEffect(isPulsing ? 1.5 : 1.0)
.opacity(isPulsing ? 0.0 : 0.8)
.onAppear {
withAnimation(
Animation.easeInOut(duration: 1.5)
.repeatForever(autoreverses: false)
) {
isPulsing = true
}
}
}
}
// 线
enum ScanningLineStyle: String, CaseIterable {
case modern = "style_modern"
case classic = "style_classic"
case neon = "style_neon"
case minimal = "style_minimal"
case retro = "style_retro"
var localizedName: String {
return self.rawValue.localized
}
}
4 months ago
// 线
struct ScanningLineView: View {
let style: ScanningLineStyle
var body: some View {
switch style {
case .modern:
ModernScanningLine()
case .classic:
ClassicScanningLine()
case .neon:
NeonScanningLine()
case .minimal:
MinimalScanningLine()
case .retro:
RetroScanningLine()
}
}
}
// 线
struct ModernScanningLine: View {
var body: some View {
ZStack {
//
Rectangle()
.fill(LinearGradient(
colors: [Color.clear, Color.green.opacity(0.3), Color.clear],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 320, height: 8)
.blur(radius: 3)
// 线
Rectangle()
.fill(LinearGradient(
colors: [Color.clear, Color.green, Color.clear],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 300, height: 3)
.shadow(color: .green, radius: 2, x: 0, y: 0)
//
Rectangle()
.fill(LinearGradient(
colors: [Color.clear, Color.green.opacity(0.8), Color.clear],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 300, height: 1)
.blur(radius: 1)
//
Circle()
.fill(Color.green)
.frame(width: 8, height: 8)
.shadow(color: .green, radius: 4, x: 0, y: 0)
//
Circle()
.stroke(Color.green.opacity(0.6), lineWidth: 2)
.frame(width: 16, height: 16)
//
Circle()
.stroke(Color.green.opacity(0.4), lineWidth: 1)
.frame(width: 24, height: 24)
.scaleEffect(1.0)
.opacity(0.8)
.modifier(PulseAnimationModifier())
}
.modifier(ScanningLineModifier())
}
}
// 线
struct ClassicScanningLine: View {
var body: some View {
Rectangle()
.fill(Color.green)
.frame(width: 300, height: 2)
.modifier(ScanningLineModifier())
}
}
// 线
struct NeonScanningLine: View {
var body: some View {
ZStack {
//
Rectangle()
.fill(LinearGradient(
colors: [Color.clear, Color.cyan.opacity(0.6), Color.clear],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 340, height: 12)
.blur(radius: 4)
// 线
Rectangle()
.fill(LinearGradient(
colors: [Color.cyan, Color.blue, Color.cyan],
startPoint: .leading,
endPoint: .trailing
))
.frame(width: 300, height: 4)
.shadow(color: .cyan, radius: 6, x: 0, y: 0)
//
Circle()
.fill(Color.white)
.frame(width: 6, height: 6)
.shadow(color: .white, radius: 8, x: 0, y: 0)
}
.modifier(ScanningLineModifier())
}
}
// 线
struct MinimalScanningLine: View {
var body: some View {
Rectangle()
.fill(Color.white.opacity(0.8))
.frame(width: 280, height: 1)
.modifier(ScanningLineModifier())
}
}
// 线
struct RetroScanningLine: View {
var body: some View {
ZStack {
// 线
Rectangle()
.fill(LinearGradient(
colors: [Color.clear, Color.orange, Color.clear],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 300, height: 3)
.overlay(
Rectangle()
.stroke(Color.orange, lineWidth: 1)
.frame(width: 300, height: 3)
)
//
Circle()
.fill(Color.orange)
.frame(width: 4, height: 4)
}
.modifier(ScanningLineModifier())
}
}
#if DEBUG
struct ScannerView_Previews: PreviewProvider {
static var previews: some View {
ScannerView()
}
}
#endif