jsdw_ios/QuickLocation/Component/CameraCapture/CameraCaptureView.swift

764 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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()
}
}
}