// // CameraCaptureView.swift // QuickLocation // import UIKit import AVFoundation enum CameraCaptureMode { case scan case photo case video } final class CameraCaptureView: UIView { var onClose: (() -> Void)? var onScan: ((String) -> Void)? var onPhoto: ((UIImage) -> Void)? var onVideo: ((URL) -> Void)? var onOpenAlbum: (() -> Void)? var maxVideoDuration: TimeInterval = 15 private(set) var mode: CameraCaptureMode private(set) var isRecording = false private let session = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "camera.capture.session") private var previewLayer: AVCaptureVideoPreviewLayer? private var videoInput: AVCaptureDeviceInput? private var audioInput: AVCaptureDeviceInput? private var metaOutput: AVCaptureMetadataOutput? private var photoOutput: AVCapturePhotoOutput? private var movieOutput: AVCaptureMovieFileOutput? private var cameraPosition: AVCaptureDevice.Position = .back private var isMuted = false private var isTorchOn = false private var didHandleScan = false private var recordingTimer: Timer? private var recordingStartedAt: Date? private var progressDisplayLink: CADisplayLink? private var capturedImage: UIImage? private var isReviewingPhoto = false init(mode: CameraCaptureMode) { self.mode = mode super.init(frame: .zero) backgroundColor = .black setupUI() applyModeUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { metaOutput?.setMetadataObjectsDelegate(nil, queue: nil) progressDisplayLink?.invalidate() scanLineView.layer.removeAllAnimations() let session = self.session sessionQueue.async { if session.isRunning { session.stopRunning() } } } // MARK: - Public func startSession() { didHandleScan = false requestAccessAndConfigure() if mode == .scan { startScanLine() } } func stopSession() { stopScanLine() stopRecordingTimer() metaOutput?.setMetadataObjectsDelegate(nil, queue: nil) let session = self.session sessionQueue.async { if session.isRunning { session.stopRunning() } } } func setMode(_ mode: CameraCaptureMode) { guard self.mode != mode, !isRecording else { return } self.mode = mode didHandleScan = false applyModeUI() sessionQueue.async { [weak self] in self?.reconfigureOutputs() } if mode == .scan { startScanLine() } else { stopScanLine() } } // MARK: - UI private lazy var backBtn: UIButton = { let btn = UIButton(type: .custom) btn.backgroundColor = UIColor.white.withAlphaComponent(0.28) btn.cornerRadius = 10 let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) btn.setImage(UIImage(systemName: "chevron.left", withConfiguration: config), for: .normal) btn.tintColor = .white btn.extendEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) btn.addTarget(self, action: #selector(tapBack), for: .touchUpInside) return btn }() private lazy var scanBoxView: UIView = { let view = UIView() view.backgroundColor = .clear view.clipsToBounds = true view.isUserInteractionEnabled = false return view }() private lazy var scanFrameView: UIImageView = { let iv = UIImageView(image: UIImage(named: "Camera/scan_frame")) iv.contentMode = .scaleAspectFit return iv }() private lazy var scanLineView: UIView = { let view = UIView() view.backgroundColor = .white view.layer.shadowColor = UIColor.white.cgColor view.layer.shadowRadius = 4 view.layer.shadowOpacity = 0.9 view.layer.shadowOffset = .zero return view }() private lazy var leftBtn: UIButton = { let btn = UIButton(type: .custom) btn.imageView?.contentMode = .scaleAspectFit btn.addTarget(self, action: #selector(tapLeft), for: .touchUpInside) return btn }() private lazy var rightBtn: UIButton = { let btn = UIButton(type: .custom) btn.imageView?.contentMode = .scaleAspectFit btn.addTarget(self, action: #selector(tapRight), for: .touchUpInside) return btn }() private lazy var shutterBtn: CameraShutterButton = { let btn = CameraShutterButton() btn.addTarget(self, action: #selector(tapShutter), for: .touchUpInside) return btn }() private lazy var timerLab: UILabel = { let label = UILabel() label.font = .monospacedDigitSystemFont(ofSize: 16, weight: .medium) label.textColor = .white label.textAlignment = .center label.isHidden = true return label }() private lazy var previewImageView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.clipsToBounds = true iv.isHidden = true iv.isUserInteractionEnabled = false iv.backgroundColor = .black return iv }() private lazy var retakeBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("重拍", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.backgroundColor = UIColor.white.withAlphaComponent(0.22) btn.cornerRadius = 25 btn.isHidden = true btn.addTarget(self, action: #selector(tapRetake), for: .touchUpInside) return btn }() private lazy var doneBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("完成", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.backgroundColor = UIColor(hexStr: "#3ED0FF") btn.cornerRadius = 25 btn.isHidden = true btn.addTarget(self, action: #selector(tapDone), for: .touchUpInside) return btn }() private func setupUI() { addSubview(scanBoxView) scanBoxView.addSubview(scanFrameView) scanBoxView.addSubview(scanLineView) addSubview(backBtn) addSubview(leftBtn) addSubview(rightBtn) addSubview(shutterBtn) addSubview(timerLab) addSubview(previewImageView) addSubview(retakeBtn) addSubview(doneBtn) backBtn.layoutChain .left(16) .width(36) .height(36) backBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true scanBoxView.layoutChain .centerY() .centerX() .width(327) .height(324) scanFrameView.layoutChain.edges() shutterBtn.layoutChain .centerX() .bottom(kSafeBottomMargin + 36) .width(76) .height(76) leftBtn.layoutChain .centerY(shutterBtn) .left(36) .width(50) .height(50) rightBtn.layoutChain .centerY(shutterBtn) .right(36) .width(50) .height(50) timerLab.layoutChain .centerX() .bottomToTopOfView(shutterBtn, offset: -14) previewImageView.layoutChain.edges() retakeBtn.layoutChain .left(20) .bottom(kSafeBottomMargin + 20) .height(50) .widthToView(doneBtn) doneBtn.layoutChain .leftToRightOfView(retakeBtn, offset: 12) .right(20) .bottomToView(retakeBtn) .height(50) raiseOverlayControls() } private func raiseOverlayControls() { bringSubviewToFront(backBtn) bringSubviewToFront(retakeBtn) bringSubviewToFront(doneBtn) } private func applyModeUI() { let reviewing = isReviewingPhoto previewImageView.isHidden = !reviewing retakeBtn.isHidden = !reviewing doneBtn.isHidden = !reviewing leftBtn.isHidden = reviewing rightBtn.isHidden = reviewing shutterBtn.isHidden = reviewing if reviewing { scanBoxView.isHidden = true timerLab.isHidden = true return } let scanning = mode == .scan scanBoxView.isHidden = !scanning shutterBtn.isHidden = scanning timerLab.isHidden = !(mode == .video && isRecording) shutterBtn.isRecordingStyle = isRecording switch mode { case .scan: leftBtn.setImage(UIImage(named: "Camera/btn_flash"), for: .normal) rightBtn.setImage(UIImage(named: "Camera/btn_album"), for: .normal) case .photo: leftBtn.setImage(UIImage(named: "Camera/btn_flash"), for: .normal) rightBtn.setImage(UIImage(named: "Camera/btn_flip"), for: .normal) case .video: if isRecording { leftBtn.setImage(UIImage(named: "Camera/btn_mute"), for: .normal) leftBtn.alpha = isMuted ? 0.45 : 1 } else { leftBtn.setImage(UIImage(named: "Camera/btn_flash"), for: .normal) leftBtn.alpha = 1 } rightBtn.setImage(UIImage(named: "Camera/btn_flip"), for: .normal) } } override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = bounds raiseOverlayControls() if scanLineView.layer.animation(forKey: "scan") == nil, mode == .scan, !scanBoxView.isHidden { startScanLine() } } // MARK: - Scan line func startScanLine() { guard mode == .scan else { return } layoutIfNeeded() let box = scanBoxView.bounds guard box.height > 20 else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.startScanLine() } return } scanLineView.layer.removeAnimation(forKey: "scan") let inset: CGFloat = 18 scanLineView.bounds = CGRect(x: 0, y: 0, width: max(box.width - inset * 2, 40), height: 2) scanLineView.layer.cornerRadius = 1 let minY = inset let maxY = box.height - inset scanLineView.center = CGPoint(x: box.midX, y: minY) let anim = CABasicAnimation(keyPath: "position.y") anim.fromValue = minY anim.toValue = maxY anim.duration = 1.6 anim.autoreverses = false anim.repeatCount = .infinity anim.timingFunction = CAMediaTimingFunction(name: .linear) scanLineView.layer.add(anim, forKey: "scan") scanLineView.isHidden = false } func stopScanLine() { scanLineView.layer.removeAnimation(forKey: "scan") scanLineView.isHidden = true } // MARK: - Session private func requestAccessAndConfigure() { let status = AVCaptureDevice.authorizationStatus(for: .video) switch status { case .authorized: configureSession() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { if granted { self?.configureSession() } else { DLToast.show(text: "请在设置中开启相机权限") } } } default: DLToast.show(text: "请在设置中开启相机权限") } } private func configureSession() { sessionQueue.async { [weak self] in guard let self else { return } self.session.beginConfiguration() self.session.sessionPreset = .high self.attachVideoInput(position: self.cameraPosition) if self.mode == .video { self.attachAudioInput() } self.reconfigureOutputsLocked() self.session.commitConfiguration() self.applyMetadataTypes() let layer = AVCaptureVideoPreviewLayer(session: self.session) layer.videoGravity = .resizeAspectFill DispatchQueue.main.async { self.previewLayer?.removeFromSuperlayer() layer.frame = self.bounds self.layer.insertSublayer(layer, at: 0) self.previewLayer = layer self.raiseOverlayControls() if self.mode == .scan { self.startScanLine() } } if !self.session.isRunning { self.session.startRunning() } self.applyMetadataTypes() } } private func reconfigureOutputs() { session.beginConfiguration() reconfigureOutputsLocked() session.commitConfiguration() applyMetadataTypes() } private func reconfigureOutputsLocked() { if let metaOutput { session.removeOutput(metaOutput) self.metaOutput = nil } if let photoOutput { session.removeOutput(photoOutput) self.photoOutput = nil } if let movieOutput { session.removeOutput(movieOutput) self.movieOutput = nil } switch mode { case .scan: let output = AVCaptureMetadataOutput() if session.canAddOutput(output) { session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) metaOutput = output } case .photo: let output = AVCapturePhotoOutput() if session.canAddOutput(output) { session.addOutput(output) photoOutput = output } case .video: attachAudioInput() let output = AVCaptureMovieFileOutput() if session.canAddOutput(output) { session.addOutput(output) if let conn = output.connection(with: .video), conn.isVideoStabilizationSupported { conn.preferredVideoStabilizationMode = .auto } movieOutput = output } } } @discardableResult private func attachVideoInput(position: AVCaptureDevice.Position) -> Bool { if let videoInput { session.removeInput(videoInput) self.videoInput = nil } guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return false } session.addInput(input) videoInput = input cameraPosition = position return true } private func attachAudioInput() { guard audioInput == nil else { return } guard let device = AVCaptureDevice.default(for: .audio), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return } session.addInput(input) audioInput = input } /// 会话 commit / startRunning 之后再设类型,否则 availableMetadataObjectTypes 经常是空的。 private func applyMetadataTypes() { guard mode == .scan, let output = metaOutput else { return } let preferred: [AVMetadataObject.ObjectType] = [ .qr, .code128, .ean13, .ean8, .upce, .code39, .code39Mod43 ] let available = output.availableMetadataObjectTypes let types = preferred.filter { available.contains($0) } if !types.isEmpty { output.metadataObjectTypes = types } else { output.metadataObjectTypes = [.qr] } } // MARK: - Actions @objc private func tapBack() { if isRecording { stopRecording() } onClose?() } @objc private func tapLeft() { switch mode { case .scan, .photo: toggleTorch() case .video: if isRecording { toggleMute() } else { toggleTorch() } } } @objc private func tapRight() { switch mode { case .scan: onOpenAlbum?() case .photo, .video: switchCamera() } } @objc private func tapRetake() { capturedImage = nil isReviewingPhoto = false applyModeUI() } @objc private func tapDone() { guard let image = capturedImage else { return } onPhoto?(image) } @objc private func tapShutter() { switch mode { case .scan: break case .photo: capturePhoto() case .video: if isRecording { stopRecording() } else { startRecording() } } } private func toggleTorch() { guard cameraPosition == .back, let device = videoInput?.device, device.hasTorch else { DLToast.show(text: "当前镜头不支持闪光灯") return } do { try device.lockForConfiguration() isTorchOn.toggle() device.torchMode = isTorchOn ? .on : .off device.unlockForConfiguration() } catch { DLToast.show(text: "闪光灯开启失败") } } private func toggleMute() { isMuted.toggle() if let conn = movieOutput?.connection(with: .audio) { conn.isEnabled = !isMuted } applyModeUI() } private func switchCamera() { let next: AVCaptureDevice.Position = cameraPosition == .back ? .front : .back sessionQueue.async { [weak self] in guard let self else { return } self.session.beginConfiguration() _ = self.attachVideoInput(position: next) if self.isTorchOn, next == .front { self.isTorchOn = false } self.session.commitConfiguration() } } private func capturePhoto() { guard let photoOutput else { return } let settings = AVCapturePhotoSettings() if let device = videoInput?.device, device.hasFlash { settings.flashMode = isTorchOn ? .on : .off } photoOutput.capturePhoto(with: settings, delegate: self) } private func startRecording() { let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) if micStatus == .notDetermined { AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in DispatchQueue.main.async { if granted { self?.startRecording() } else { DLToast.show(text: "请在设置中开启麦克风权限") } } } return } guard let movieOutput, !movieOutput.isRecording else { return } let url = FileManager.default.temporaryDirectory.appendingPathComponent("cap_\(Int(Date().timeIntervalSince1970)).mov") try? FileManager.default.removeItem(at: url) if let conn = movieOutput.connection(with: .audio) { conn.isEnabled = !isMuted } movieOutput.maxRecordedDuration = CMTime(seconds: maxVideoDuration, preferredTimescale: 600) movieOutput.startRecording(to: url, recordingDelegate: self) isRecording = true recordingStartedAt = Date() applyModeUI() timerLab.isHidden = false timerLab.text = "00:00" startRecordingTimer() } private func stopRecording() { movieOutput?.stopRecording() isRecording = false stopRecordingTimer() applyModeUI() timerLab.isHidden = true shutterBtn.progress = 0 } private func startRecordingTimer() { stopRecordingTimer() let link = CADisplayLink(target: self, selector: #selector(tickRecording)) link.add(to: .main, forMode: .common) progressDisplayLink = link } private func stopRecordingTimer() { progressDisplayLink?.invalidate() progressDisplayLink = nil recordingTimer?.invalidate() recordingTimer = nil } @objc private func tickRecording() { guard let start = recordingStartedAt else { return } let elapsed = Date().timeIntervalSince(start) let minutes = Int(elapsed) / 60 let seconds = Int(elapsed) % 60 timerLab.text = String(format: "%02d:%02d", minutes, seconds) shutterBtn.progress = CGFloat(min(elapsed / maxVideoDuration, 1)) if elapsed >= maxVideoDuration { stopRecording() } } } // MARK: - Scan extension CameraCaptureView: AVCaptureMetadataOutputObjectsDelegate { func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard mode == .scan, !didHandleScan, let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let text = obj.stringValue, !text.isEmpty else { return } didHandleScan = true stopScanLine() onScan?(text) } } // MARK: - Photo extension CameraCaptureView: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { if let error { DLToast.show(text: "拍照失败:\(error.localizedDescription)") return } guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } self.capturedImage = image self.previewImageView.image = image self.isReviewingPhoto = true self.applyModeUI() } } } // MARK: - Video extension CameraCaptureView: AVCaptureFileOutputRecordingDelegate { func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { isRecording = false DispatchQueue.main.async { [weak self] in self?.stopRecordingTimer() self?.applyModeUI() self?.timerLab.isHidden = true self?.shutterBtn.progress = 0 } if let error { DLToast.show(text: "录像失败:\(error.localizedDescription)") return } onVideo?(outputFileURL) } } // MARK: - Shutter private final class CameraShutterButton: UIControl { var isRecordingStyle = false { didSet { setNeedsDisplay() } } var progress: CGFloat = 0 { didSet { setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { let cyan = UIColor(hexStr: "#3ED0FF") let ringW: CGFloat = 4 let inset = ringW / 2 + 1 let ringRect = rect.insetBy(dx: inset, dy: inset) if isRecordingStyle { let inner = min(rect.width, rect.height) * 0.34 let innerRect = CGRect(x: (rect.width - inner) / 2, y: (rect.height - inner) / 2, width: inner, height: inner) cyan.setFill() UIBezierPath(ovalIn: innerRect).fill() let path = UIBezierPath(ovalIn: ringRect) path.lineWidth = ringW UIColor.white.withAlphaComponent(0.25).setStroke() path.stroke() let start = -CGFloat.pi / 2 let end = start + 2 * .pi * min(max(progress, 0), 1) let arc = UIBezierPath(arcCenter: CGPoint(x: rect.midX, y: rect.midY), radius: ringRect.width / 2, startAngle: start, endAngle: end, clockwise: true) arc.lineWidth = ringW arc.lineCapStyle = .round cyan.setStroke() arc.stroke() } else { let inner = min(rect.width, rect.height) - 16 let innerRect = CGRect(x: (rect.width - inner) / 2, y: (rect.height - inner) / 2, width: inner, height: inner) cyan.setFill() UIBezierPath(ovalIn: innerRect).fill() let path = UIBezierPath(ovalIn: ringRect) path.lineWidth = ringW cyan.setStroke() path.stroke() } } }