764 lines
24 KiB
Swift
764 lines
24 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|
||
}
|