diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 89aa7cca..1ea7fde2 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -162,6 +162,13 @@ 305A79902FCAC61A00227D26 /* InviteMemberVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A798F2FCAC61A00227D26 /* InviteMemberVC.swift */; }; 3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */; }; 3062E8B72FCE6BFE00CEF511 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B62FCE6BFE00CEF511 /* ScanView.swift */; }; + 30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */; }; + 30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */; }; + 30B2D0B12FEB111100CA0001 /* PhotoPickerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */; }; + 30B2D0B32FEB111100CA0001 /* PhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */; }; + 30B2D0B52FEB111100CA0001 /* PhotoPickerAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */; }; + 30B2D0B72FEB111100CA0001 /* PhotoPickerPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */; }; + 30B2D0B92FEB111100CA0001 /* PhotoPickerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */; }; 3062E8BA2FCEAC6500CEF511 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B92FCEAC6500CEF511 /* CreateGroupView.swift */; }; 3062E8BC2FCEAC7100CEF511 /* CreateGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */; }; 3062E8BE2FCEBD0E00CEF511 /* GroupIconListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.swift */; }; @@ -580,6 +587,13 @@ 305A798F2FCAC61A00227D26 /* InviteMemberVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteMemberVC.swift; sourceTree = ""; }; 3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanVC.swift; sourceTree = ""; }; 3062E8B62FCE6BFE00CEF511 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + 30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureView.swift; sourceTree = ""; }; + 30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureVC.swift; sourceTree = ""; }; + 30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerStore.swift; sourceTree = ""; }; + 30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerView.swift; sourceTree = ""; }; + 30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerAlbumView.swift; sourceTree = ""; }; + 30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerPreviewVC.swift; sourceTree = ""; }; + 30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerVC.swift; sourceTree = ""; }; 3062E8B92FCEAC6500CEF511 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; 3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupVC.swift; sourceTree = ""; }; 3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupIconListVC.swift; sourceTree = ""; }; @@ -963,6 +977,8 @@ 305A74E72FCA8C7000227D26 /* Component */ = { isa = PBXGroup; children = ( + 30A1C0A42FEB000100CA0001 /* CameraCapture */, + 30B2D0BA2FEB111100CA0001 /* PhotoPicker */, 305A74D72FCA8C7000227D26 /* AddImageCell */, 305A74DB2FCA8C7000227D26 /* CornerRadiusView */, 305A74E22FCA8C7000227D26 /* ImagePicker */, @@ -971,6 +987,27 @@ path = Component; sourceTree = ""; }; + 30A1C0A42FEB000100CA0001 /* CameraCapture */ = { + isa = PBXGroup; + children = ( + 30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */, + 30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */, + ); + path = CameraCapture; + sourceTree = ""; + }; + 30B2D0BA2FEB111100CA0001 /* PhotoPicker */ = { + isa = PBXGroup; + children = ( + 30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */, + 30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */, + 30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */, + 30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */, + 30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */, + ); + path = PhotoPicker; + sourceTree = ""; + }; 305A74EA2FCA8C7000227D26 /* RTL */ = { isa = PBXGroup; children = ( @@ -2538,6 +2575,13 @@ 305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */, 305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */, 3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */, + 30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */, + 30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */, + 30B2D0B12FEB111100CA0001 /* PhotoPickerStore.swift in Sources */, + 30B2D0B32FEB111100CA0001 /* PhotoPickerView.swift in Sources */, + 30B2D0B52FEB111100CA0001 /* PhotoPickerAlbumView.swift in Sources */, + 30B2D0B72FEB111100CA0001 /* PhotoPickerPreviewVC.swift in Sources */, + 30B2D0B92FEB111100CA0001 /* PhotoPickerVC.swift in Sources */, 305A76E72FCA8C7000227D26 /* LoginView.swift in Sources */, 305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */, 30EFF3DC2FDA981B00EB35D4 /* EmergencyContactAddView.swift in Sources */, diff --git a/QuickLocation/Assets.xcassets/Camera/Contents.json b/QuickLocation/Assets.xcassets/Camera/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json new file mode 100644 index 00000000..20cb433a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_flash@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_flash@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@2x.png b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@2x.png new file mode 100644 index 00000000..ed8053aa Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@3x.png new file mode 100644 index 00000000..54b73456 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_album.imageset/btn_flash@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/Contents.json new file mode 100644 index 00000000..b24e7427 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_flip@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_flip@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@2x.png b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@2x.png new file mode 100644 index 00000000..a2b3f4ce Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@3x.png new file mode 100644 index 00000000..6ade5b67 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_flash.imageset/btn_flip@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/Contents.json new file mode 100644 index 00000000..51516385 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_album@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_album@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@2x.png b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@2x.png new file mode 100644 index 00000000..e8b90637 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@3x.png new file mode 100644 index 00000000..acef7152 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_flip.imageset/btn_album@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json new file mode 100644 index 00000000..23426939 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_mute@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_mute@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@2x.png b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@2x.png new file mode 100644 index 00000000..aaaed8e8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@3x.png b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@3x.png new file mode 100644 index 00000000..3f6226c8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/btn_mute.imageset/btn_mute@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json new file mode 100644 index 00000000..6674d71c --- /dev/null +++ b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "scan_frame@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "scan_frame@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@2x.png b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@2x.png new file mode 100644 index 00000000..64399c5d Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@3x.png b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@3x.png new file mode 100644 index 00000000..2da29c44 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Camera/scan_frame.imageset/scan_frame@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/Contents.json b/QuickLocation/Assets.xcassets/PhotoPicker/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PhotoPicker/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json new file mode 100644 index 00000000..4d8797c4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_album_arrow@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_album_arrow@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png new file mode 100644 index 00000000..52d631b1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png new file mode 100644 index 00000000..50dcbaf3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/Contents.json b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/Contents.json new file mode 100644 index 00000000..291db0ef --- /dev/null +++ b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "btn_close@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "btn_close@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@2x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@2x.png new file mode 100644 index 00000000..692ac13f Binary files /dev/null and b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png new file mode 100644 index 00000000..f890703c Binary files /dev/null and b/QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png differ diff --git a/QuickLocation/Component/CameraCapture/CameraCaptureVC.swift b/QuickLocation/Component/CameraCapture/CameraCaptureVC.swift new file mode 100644 index 00000000..5ae2b0f0 --- /dev/null +++ b/QuickLocation/Component/CameraCapture/CameraCaptureVC.swift @@ -0,0 +1,71 @@ +// +// CameraCaptureVC.swift +// QuickLocation +// + +import UIKit + +final class CameraCaptureVC: BaseViewController { + + override var isNavigationBarHidden: Bool { true } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + + private let captureMode: CameraCaptureMode + private var rootView: CameraCaptureView! + + var onScan: ((String) -> Void)? + var onPhoto: ((UIImage) -> Void)? + var onVideo: ((URL) -> Void)? + var onOpenAlbum: (() -> Void)? + + init(mode: CameraCaptureMode) { + self.captureMode = mode + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + rootView = CameraCaptureView(mode: captureMode) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.onClose = { [weak self] in + self?.closeCapture() + } + rootView.onScan = { [weak self] text in + self?.onScan?(text) + } + rootView.onPhoto = { [weak self] image in + self?.onPhoto?(image) + } + rootView.onVideo = { [weak self] url in + self?.onVideo?(url) + } + rootView.onOpenAlbum = { [weak self] in + self?.onOpenAlbum?() + } + } + + private func closeCapture() { + if presentingViewController != nil { + dismiss(animated: true) + } else { + navigationController?.popViewController(animated: true) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + rootView.startSession() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + rootView.stopSession() + } +} diff --git a/QuickLocation/Component/CameraCapture/CameraCaptureView.swift b/QuickLocation/Component/CameraCapture/CameraCaptureView.swift new file mode 100644 index 00000000..aa346164 --- /dev/null +++ b/QuickLocation/Component/CameraCapture/CameraCaptureView.swift @@ -0,0 +1,763 @@ +// +// 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() + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift new file mode 100644 index 00000000..d116dd75 --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerAlbumView.swift @@ -0,0 +1,112 @@ +// +// PhotoPickerAlbumView.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerAlbumView: UIView, UITableViewDataSource, UITableViewDelegate { + + var onSelect: ((Int) -> Void)? + + private var albums: [PhotoAlbum] = [] + private var selectedIndex = 0 + private var store: PhotoPickerStore? + + private lazy var tableView: UITableView = { + let tv = UITableView(frame: .zero, style: .plain) + tv.backgroundColor = UIColor(hexStr: "#2C2C2E") + tv.separatorColor = UIColor.white.withAlphaComponent(0.12) + tv.separatorInset = UIEdgeInsets(top: 0, left: 76, bottom: 0, right: 0) + tv.rowHeight = 64 + tv.dataSource = self + tv.delegate = self + tv.tableFooterView = UIView() + tv.register(PhotoPickerAlbumCell.self, forCellReuseIdentifier: "cell") + return tv + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#2C2C2E") + addSubview(tableView) + tableView.layoutChain.edges() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func reload(albums: [PhotoAlbum], selectedIndex: Int) { + self.albums = albums + self.selectedIndex = selectedIndex + tableView.reloadData() + } + + func bindStore(_ store: PhotoPickerStore) { + self.store = store + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + albums.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! PhotoPickerAlbumCell + let album = albums[indexPath.row] + cell.configure(album: album, selected: indexPath.row == selectedIndex, store: store) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + selectedIndex = indexPath.row + tableView.reloadData() + onSelect?(indexPath.row) + } +} + +private final class PhotoPickerAlbumCell: UITableViewCell { + private let coverView = UIImageView() + private let titleLab = UILabel() + private let tickView = UIImageView() + private var assetId = "" + private var requestId: PHImageRequestID = PHInvalidImageRequestID + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = UIColor(hexStr: "#2C2C2E") + selectionStyle = .none + coverView.contentMode = .scaleAspectFill + coverView.clipsToBounds = true + coverView.cornerRadius = 4 + coverView.backgroundColor = UIColor.white.withAlphaComponent(0.08) + titleLab.font = .systemFont(ofSize: 16) + titleLab.textColor = .white + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold) + tickView.image = UIImage(systemName: "checkmark", withConfiguration: config) + tickView.tintColor = UIColor(hexStr: "#007AFF") + tickView.contentMode = .scaleAspectFit + contentView.addSubview(coverView) + contentView.addSubview(titleLab) + contentView.addSubview(tickView) + coverView.layoutChain.left(16).centerY().width(48).height(48) + titleLab.layoutChain.leftToRightOfView(coverView, offset: 12).centerY().right(40) + tickView.layoutChain.right(16).centerY().width(18).height(18) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(album: PhotoAlbum, selected: Bool, store: PhotoPickerStore?) { + titleLab.text = "\(album.title) (\(album.count))" + tickView.isHidden = !selected + coverView.image = nil + assetId = album.coverAsset?.localIdentifier ?? "" + guard let store, let asset = album.coverAsset else { return } + if requestId != PHInvalidImageRequestID { + store.cancelRequest(requestId) + } + requestId = store.requestThumbnail(for: asset, size: CGSize(width: 48, height: 48)) { [weak self] image in + guard let self, self.assetId == asset.localIdentifier else { return } + self.coverView.image = image + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift new file mode 100644 index 00000000..3b157a4a --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerPreviewVC.swift @@ -0,0 +1,163 @@ +// +// PhotoPickerPreviewVC.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerPreviewVC: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + + private let assets: [PHAsset] + private let store: PhotoPickerStore + private var currentIndex: Int + private var lastPageSize: CGSize = .zero + + init(assets: [PHAsset], startIndex: Int, store: PhotoPickerStore) { + self.assets = assets + self.store = store + self.currentIndex = min(max(startIndex, 0), max(assets.count - 1, 0)) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private lazy var closeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "PhotoPicker/btn_close"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + btn.addTarget(self, action: #selector(tapClose), for: .touchUpInside) + return btn + }() + + private lazy var titleLab: UILabel = { + let label = UILabel() + label.text = "预览" + label.textColor = .white + label.font = .systemFont(ofSize: 17, weight: .medium) + return label + }() + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.isPagingEnabled = true + cv.showsHorizontalScrollIndicator = false + cv.backgroundColor = .black + cv.dataSource = self + cv.delegate = self + cv.register(PhotoPickerPreviewCell.self, forCellWithReuseIdentifier: "cell") + return cv + }() + + private lazy var pageLab: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textAlignment = .center + label.backgroundColor = UIColor.black.withAlphaComponent(0.45) + label.cornerRadius = 12 + label.clipsToBounds = true + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + view.addSubview(collectionView) + view.addSubview(closeBtn) + view.addSubview(titleLab) + view.addSubview(pageLab) + + collectionView.layoutChain.edges() + closeBtn.layoutChain.left(16).width(32).height(32) + closeBtn.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 6).isActive = true + titleLab.layoutChain.centerX().centerY(closeBtn) + pageLab.layoutChain.centerX().width(52).height(24) + pageLab.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16).isActive = true + updatePageLabel() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let size = collectionView.bounds.size + guard size.width > 1 else { return } + if size != lastPageSize { + lastPageSize = size + collectionView.collectionViewLayout.invalidateLayout() + } + guard assets.indices.contains(currentIndex) else { return } + let offset = CGFloat(currentIndex) * size.width + if abs(collectionView.contentOffset.x - offset) > 1 { + collectionView.setContentOffset(CGPoint(x: offset, y: 0), animated: false) + } + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + assets.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! PhotoPickerPreviewCell + cell.configure(asset: assets[indexPath.item], store: store) + return cell + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + collectionView.bounds.size + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0 else { return } + let index = Int(round(scrollView.contentOffset.x / width)) + let clamped = min(max(index, 0), max(assets.count - 1, 0)) + if clamped != currentIndex { + currentIndex = clamped + updatePageLabel() + } + } + + private func updatePageLabel() { + pageLab.text = "\(currentIndex + 1)/\(max(assets.count, 1))" + } + + @objc private func tapClose() { + dismiss(animated: true) + } +} + +private final class PhotoPickerPreviewCell: UICollectionViewCell { + private let imageView = UIImageView() + private var assetId = "" + private var requestId: PHImageRequestID = PHInvalidImageRequestID + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .black + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.layoutChain.edges() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(asset: PHAsset, store: PhotoPickerStore) { + if requestId != PHInvalidImageRequestID { + store.cancelRequest(requestId) + } + assetId = asset.localIdentifier + imageView.image = nil + let size = UIScreen.main.bounds.size + requestId = store.requestThumbnail(for: asset, size: size) { [weak self] image in + guard let self, self.assetId == asset.localIdentifier else { return } + self.imageView.image = image + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift new file mode 100644 index 00000000..187907ea --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerStore.swift @@ -0,0 +1,169 @@ +// +// PhotoPickerStore.swift +// QuickLocation +// + +import UIKit +import Photos + +struct PhotoAlbum { + let collection: PHAssetCollection + let title: String + let count: Int + let coverAsset: PHAsset? +} + +final class PhotoPickerStore { + + private let imageManager = PHCachingImageManager() + private let imageOptions: PHImageRequestOptions = { + let options = PHImageRequestOptions() + options.deliveryMode = .opportunistic + options.resizeMode = .fast + options.isNetworkAccessAllowed = true + return options + }() + + private(set) var albums: [PhotoAlbum] = [] + private(set) var assets: [PHAsset] = [] + + func requestAccess(_ completion: @escaping (Bool) -> Void) { + let finish: (PHAuthorizationStatus) -> Void = { status in + DispatchQueue.main.async { + if #available(iOS 14, *) { + completion(status == .authorized || status == .limited) + } else { + completion(status == .authorized) + } + } + } + if #available(iOS 14, *) { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if status == .notDetermined { + PHPhotoLibrary.requestAuthorization(for: .readWrite, handler: finish) + } else { + finish(status) + } + } else { + let status = PHPhotoLibrary.authorizationStatus() + if status == .notDetermined { + PHPhotoLibrary.requestAuthorization(finish) + } else { + finish(status) + } + } + } + + func reloadAlbums() { + var list: [PhotoAlbum] = [] + let smart = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any, options: nil) + smart.enumerateObjects { collection, _, _ in + guard let album = self.makeAlbum(collection) else { return } + if collection.assetCollectionSubtype == .smartAlbumUserLibrary { + list.insert(album, at: 0) + } else { + list.append(album) + } + } + let user = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: nil) + user.enumerateObjects { collection, _, _ in + guard let album = self.makeAlbum(collection) else { return } + list.append(album) + } + albums = list + if let first = albums.first { + loadAssets(in: first) + } else { + assets = [] + } + } + + func loadAssets(in album: PhotoAlbum) { + assets = fetchImages(in: album.collection) + } + + func requestThumbnail(for asset: PHAsset, size: CGSize, completion: @escaping (UIImage?) -> Void) -> PHImageRequestID { + let scale = UIScreen.main.scale + let target = CGSize(width: size.width * scale, height: size.height * scale) + return imageManager.requestImage(for: asset, + targetSize: target, + contentMode: .aspectFill, + options: imageOptions) { image, info in + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false + if image != nil || !degraded { + completion(image) + } + } + } + + func cancelRequest(_ id: PHImageRequestID) { + imageManager.cancelImageRequest(id) + } + + func requestFullImages(for assets: [PHAsset], completion: @escaping ([UIImage]) -> Void) { + guard !assets.isEmpty else { + completion([]) + return + } + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.resizeMode = .none + options.isNetworkAccessAllowed = true + options.isSynchronous = false + + let group = DispatchGroup() + var images: [UIImage?] = Array(repeating: nil, count: assets.count) + var finished = Array(repeating: false, count: assets.count) + let lock = NSLock() + let maxSide = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * UIScreen.main.scale * 2 + let target = CGSize(width: maxSide, height: maxSide) + + for (index, asset) in assets.enumerated() { + group.enter() + imageManager.requestImage(for: asset, + targetSize: target, + contentMode: .aspectFit, + options: options) { image, info in + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false + let cancelled = (info?[PHImageCancelledKey] as? Bool) ?? false + let error = info?[PHImageErrorKey] as? Error + lock.lock() + let already = finished[index] + if !already, !degraded || image == nil || cancelled || error != nil { + finished[index] = true + if !degraded { images[index] = image } + lock.unlock() + group.leave() + } else { + lock.unlock() + } + } + } + group.notify(queue: .main) { + completion(images.compactMap { $0 }) + } + } + + private func makeAlbum(_ collection: PHAssetCollection) -> PhotoAlbum? { + if collection.assetCollectionSubtype == .smartAlbumAllHidden { return nil } + let hiddenTitles: Set = ["最近删除", "Recently Deleted", "Hidden", "已隐藏"] + if let title = collection.localizedTitle, hiddenTitles.contains(title) { return nil } + let images = fetchImages(in: collection) + guard !images.isEmpty else { return nil } + let title = collection.localizedTitle?.isEmpty == false ? collection.localizedTitle! : "相册" + return PhotoAlbum(collection: collection, title: title, count: images.count, coverAsset: images.first) + } + + private func fetchImages(in collection: PHAssetCollection) -> [PHAsset] { + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue) + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let result = PHAsset.fetchAssets(in: collection, options: options) + var list: [PHAsset] = [] + list.reserveCapacity(result.count) + result.enumerateObjects { asset, _, _ in + list.append(asset) + } + return list + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift new file mode 100644 index 00000000..1d5e61f4 --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerVC.swift @@ -0,0 +1,121 @@ +// +// PhotoPickerVC.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerVC: BaseViewController { + + override var isNavigationBarHidden: Bool { true } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + + var onConfirm: (([UIImage]) -> Void)? + + private let maxCount: Int + private let store = PhotoPickerStore() + private var rootView: PhotoPickerView! + private var selected: [PHAsset] = [] + private var currentAlbumIndex = 0 + private var isExporting = false + + init(maxCount: Int = 9) { + self.maxCount = max(1, maxCount) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + rootView = PhotoPickerView(store: store, maxCount: maxCount) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.bindAlbumStore(store) + bind() + store.requestAccess { [weak self] granted in + guard let self else { return } + guard granted else { + DLToast.show(text: "请在设置中开启相册权限") + self.dismiss(animated: true) + return + } + self.store.reloadAlbums() + self.currentAlbumIndex = 0 + self.rootView.reloadAlbums(currentIndex: 0) + self.rootView.reloadGrid() + } + } + + private func bind() { + rootView.onClose = { [weak self] in + self?.dismiss(animated: true) + } + rootView.onToggleAlbum = { [weak self] in + guard let self else { return } + self.rootView.isAlbumOpen.toggle() + } + rootView.onPickAlbum = { [weak self] index in + guard let self, self.store.albums.indices.contains(index) else { return } + self.currentAlbumIndex = index + self.store.loadAssets(in: self.store.albums[index]) + self.rootView.reloadAlbums(currentIndex: index) + self.rootView.reloadGrid() + self.rootView.isAlbumOpen = false + } + rootView.onSelectAsset = { [weak self] index in + self?.toggleAsset(at: index) + } + rootView.onPreview = { [weak self] in + self?.showPreview() + } + rootView.onConfirm = { [weak self] in + self?.confirmSelection() + } + } + + private func toggleAsset(at index: Int) { + guard store.assets.indices.contains(index) else { return } + let asset = store.assets[index] + if let idx = selected.firstIndex(where: { $0.localIdentifier == asset.localIdentifier }) { + selected.remove(at: idx) + } else { + if selected.count >= maxCount { + rootView.showLimitToast() + return + } + selected.append(asset) + } + rootView.applySelection(selected.map(\.localIdentifier)) + } + + private func showPreview() { + guard !selected.isEmpty else { return } + let vc = PhotoPickerPreviewVC(assets: selected, startIndex: 0, store: store) + present(vc, animated: true) + } + + private func confirmSelection() { + guard !isExporting else { return } + guard !selected.isEmpty else { + dismiss(animated: true) + return + } + isExporting = true + DLToast.showLoading() + store.requestFullImages(for: selected) { [weak self] images in + DLToast.dismiss() + guard let self else { return } + self.isExporting = false + self.dismiss(animated: true) { + self.onConfirm?(images) + } + } + } +} diff --git a/QuickLocation/Component/PhotoPicker/PhotoPickerView.swift b/QuickLocation/Component/PhotoPicker/PhotoPickerView.swift new file mode 100644 index 00000000..265ba30d --- /dev/null +++ b/QuickLocation/Component/PhotoPicker/PhotoPickerView.swift @@ -0,0 +1,389 @@ +// +// PhotoPickerView.swift +// QuickLocation +// + +import UIKit +import Photos + +final class PhotoPickerView: UIView { + + var onClose: (() -> Void)? + var onToggleAlbum: (() -> Void)? + var onPreview: (() -> Void)? + var onConfirm: (() -> Void)? + var onSelectAsset: ((Int) -> Void)? + var onPickAlbum: ((Int) -> Void)? + + var isAlbumOpen = false { + didSet { applyAlbumOpen() } + } + + private let store: PhotoPickerStore + private let maxCount: Int + private var selectedIds: [String] = [] + private var currentAlbumIndex = 0 + private var lastGridWidth: CGFloat = 0 + + init(store: PhotoPickerStore, maxCount: Int) { + self.store = store + self.maxCount = maxCount + super.init(frame: .zero) + backgroundColor = UIColor(hexStr: "#2C2C2E") + setupUI() + applyAlbumOpen() + updateBottomBar(selectedCount: 0) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + let width = collectionView.bounds.width + if width > 1, abs(width - lastGridWidth) > 0.5 { + lastGridWidth = width + collectionView.collectionViewLayout.invalidateLayout() + } + } + + func reloadGrid() { + collectionView.reloadData() + } + + func bindAlbumStore(_ store: PhotoPickerStore) { + albumListView.bindStore(store) + } + + func reloadAlbums(currentIndex: Int) { + currentAlbumIndex = currentIndex + albumListView.reload(albums: store.albums, selectedIndex: currentIndex) + let title = store.albums[safe: currentIndex]?.title ?? "最近项目" + albumTitleLab.text = title + } + + func applySelection(_ ids: [String]) { + selectedIds = ids + updateBottomBar(selectedCount: ids.count) + collectionView.reloadData() + } + + func showLimitToast() { + limitBanner.isHidden = false + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(hideLimitToast), object: nil) + perform(#selector(hideLimitToast), with: nil, afterDelay: 1.8) + } + + func thumbnailSize() -> CGSize { + let side = collectionView.bounds.width > 0 + ? (collectionView.bounds.width - 3) / 4 + : (UIScreen.main.bounds.width - 3) / 4 + return CGSize(width: side, height: side) + } + + // MARK: - UI + + private lazy var closeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "PhotoPicker/btn_close"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + btn.addTarget(self, action: #selector(tapClose), for: .touchUpInside) + return btn + }() + + private lazy var albumTitleBtn: UIControl = { + let control = UIControl() + control.backgroundColor = UIColor.white.withAlphaComponent(0.12) + control.cornerRadius = 16 + control.addTarget(self, action: #selector(tapAlbum), for: .touchUpInside) + return control + }() + + private lazy var albumTitleLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = .white + label.text = "最近项目" + return label + }() + + private lazy var albumArrowView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "PhotoPicker/btn_album_arrow")) + iv.contentMode = .scaleAspectFit + return iv + }() + + lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 1 + layout.minimumLineSpacing = 1 + layout.sectionInset = .zero + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = UIColor(hexStr: "#2C2C2E") + cv.register(PhotoPickerCell.self, forCellWithReuseIdentifier: "cell") + cv.dataSource = self + cv.delegate = self + cv.alwaysBounceVertical = true + return cv + }() + + private lazy var dimView: UIControl = { + let view = UIControl() + view.backgroundColor = UIColor.black.withAlphaComponent(0.45) + view.addTarget(self, action: #selector(tapAlbum), for: .touchUpInside) + return view + }() + + private lazy var albumListView: PhotoPickerAlbumView = { + let view = PhotoPickerAlbumView() + view.onSelect = { [weak self] index in + self?.onPickAlbum?(index) + } + return view + }() + + private lazy var bottomBar: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#1C1C1E") + return view + }() + + private lazy var previewBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("预览", for: .normal) + btn.setTitleColor(UIColor.white.withAlphaComponent(0.35), for: .disabled) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16) + btn.contentHorizontalAlignment = .left + btn.addTarget(self, action: #selector(tapPreview), for: .touchUpInside) + return btn + }() + + private lazy var confirmBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("确认", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.backgroundColor = UIColor(hexStr: "#007AFF") + btn.cornerRadius = 8 + btn.addTarget(self, action: #selector(tapConfirm), for: .touchUpInside) + return btn + }() + + private lazy var limitBanner: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#3A3A3C") + view.isHidden = true + let icon = UILabel() + icon.text = "!" + icon.textColor = .white + icon.font = .systemFont(ofSize: 12, weight: .bold) + icon.textAlignment = .center + icon.backgroundColor = .clear + icon.layer.borderColor = UIColor.white.cgColor + icon.layer.borderWidth = 1.2 + icon.layer.cornerRadius = 8 + icon.clipsToBounds = true + let label = UILabel() + label.text = "最多选取\(maxCount)张图片哦~" + label.textColor = .white + label.font = .systemFont(ofSize: 14) + view.addSubview(icon) + view.addSubview(label) + icon.layoutChain.left(24).centerY().width(16).height(16) + label.layoutChain.leftToRightOfView(icon, offset: 8).centerY() + return view + }() + + private var albumListHeight: NSLayoutConstraint? + + private func setupUI() { + addSubview(closeBtn) + addSubview(albumTitleBtn) + albumTitleBtn.addSubview(albumTitleLab) + albumTitleBtn.addSubview(albumArrowView) + addSubview(collectionView) + addSubview(dimView) + addSubview(albumListView) + addSubview(limitBanner) + addSubview(bottomBar) + bottomBar.addSubview(previewBtn) + bottomBar.addSubview(confirmBtn) + + closeBtn.layoutChain + .left(16) + .width(32) + .height(32) + closeBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 6).isActive = true + + albumTitleBtn.layoutChain + .centerX() + .centerY(closeBtn) + .height(32) + + albumTitleLab.layoutChain + .left(12) + .centerY() + albumArrowView.layoutChain + .leftToRightOfView(albumTitleLab, offset: 6) + .right(8) + .centerY() + .width(20) + .height(20) + + collectionView.layoutChain + .topToBottomOfView(closeBtn, offset: 10) + .left() + .right() + + bottomBar.layoutChain + .left() + .right() + .bottom() + .topToBottomOfView(collectionView) + + let bottomH = 56 + kSafeBottomMargin + bottomBar.layoutChain.height(bottomH) + + previewBtn.layoutChain + .left(20) + .top(16) + .height(24) + confirmBtn.layoutChain + .right(16) + .top(10) + .width(88) + .height(36) + + limitBanner.layoutChain + .left() + .right() + .bottomToTopOfView(bottomBar) + .height(44) + + dimView.layoutChain + .topToBottomOfView(closeBtn, offset: 10) + .left() + .right() + .bottomToTopOfView(bottomBar) + + albumListView.layoutChain + .topToView(dimView) + .left() + .right() + albumListHeight = albumListView.heightAnchor.constraint(equalToConstant: 0) + albumListHeight?.isActive = true + } + + private func applyAlbumOpen() { + dimView.isHidden = !isAlbumOpen + albumListView.isHidden = !isAlbumOpen + let count = store.albums.count + let rowH: CGFloat = 64 + let maxH = min(CGFloat(count) * rowH, UIScreen.main.bounds.height * 0.42) + albumListHeight?.constant = isAlbumOpen ? maxH : 0 + UIView.animate(withDuration: 0.22) { + self.albumArrowView.transform = self.isAlbumOpen + ? CGAffineTransform(rotationAngle: .pi) + : .identity + self.layoutIfNeeded() + } + } + + private func updateBottomBar(selectedCount: Int) { + previewBtn.isEnabled = selectedCount > 0 + if selectedCount > 0 { + previewBtn.setTitle("预览 (\(selectedCount))", for: .normal) + } else { + previewBtn.setTitle("预览", for: .normal) + } + confirmBtn.alpha = selectedCount > 0 ? 1 : 0.55 + } + + @objc private func tapClose() { onClose?() } + @objc private func tapAlbum() { onToggleAlbum?() } + @objc private func tapPreview() { onPreview?() } + @objc private func tapConfirm() { onConfirm?() } + @objc private func hideLimitToast() { limitBanner.isHidden = true } +} + +extension PhotoPickerView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + store.assets.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! PhotoPickerCell + let asset = store.assets[indexPath.item] + let index = selectedIds.firstIndex(of: asset.localIdentifier) + cell.configure(asset: asset, selectedIndex: index, store: store, size: thumbnailSize()) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + onSelectAsset?(indexPath.item) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + thumbnailSize() + } +} + +private final class PhotoPickerCell: UICollectionViewCell { + private let imageView = UIImageView() + private let dimView = UIView() + private let badge = UILabel() + private var requestId: PHImageRequestID = PHInvalidImageRequestID + private var assetId: String = "" + + override init(frame: CGRect) { + super.init(frame: frame) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.28) + dimView.isHidden = true + badge.textAlignment = .center + badge.font = .systemFont(ofSize: 13, weight: .semibold) + badge.layer.cornerRadius = 11 + badge.clipsToBounds = true + contentView.addSubview(imageView) + contentView.addSubview(dimView) + contentView.addSubview(badge) + imageView.layoutChain.edges() + dimView.layoutChain.edges() + badge.layoutChain.top(5).right(5).width(22).height(22) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(asset: PHAsset, selectedIndex: Int?, store: PhotoPickerStore, size: CGSize) { + if requestId != PHInvalidImageRequestID { + store.cancelRequest(requestId) + } + assetId = asset.localIdentifier + imageView.image = nil + let selected = selectedIndex != nil + dimView.isHidden = !selected + if let selectedIndex { + badge.text = "\(selectedIndex + 1)" + badge.textColor = .white + badge.backgroundColor = UIColor(hexStr: "#007AFF") + badge.layer.borderWidth = 0 + } else { + badge.text = "" + badge.backgroundColor = UIColor.black.withAlphaComponent(0.18) + badge.layer.borderWidth = 1.5 + badge.layer.borderColor = UIColor.white.cgColor + } + requestId = store.requestThumbnail(for: asset, size: size) { [weak self] image in + guard let self, self.assetId == asset.localIdentifier else { return } + self.imageView.image = image + } + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + assetId = "" + } +} + diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index cd5d6ae4..bec549b8 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -13,7 +13,7 @@ import OpenIMSDK import AVFoundation import AudioToolbox import Speech -import HXPHPicker + import IQKeyboardManagerSwift import MJRefresh import CoreLocation @@ -33,7 +33,7 @@ final class GroupChatVC: BaseViewController { /// mention 列表:首行 @所有人 + 成员 private var mentionRows: [(userId: String?, nickname: String)] = [] private var isMentionPickerVisible = false - private var cameraPicker: ImagePicker? + /// 加载更早历史前记录,用于保持阅读位置 private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? private var didSetupKeyboard = false @@ -594,21 +594,13 @@ final class GroupChatVC: BaseViewController { // MARK: - 显示相册 private func showAlbum() { - // 设置与微信主题一致的配置 - let config = PhotoTools.getWXPickerConfig() - // 最多可以选择的资源数,如果为0则不限制 - config.selectOptions = [.photo] - config.selectMode = .multiple - config.maximumSelectedCount = 9 -// config.maximumSelectedPhotoFileSize = 5242880 - config.allowSyncICloudWhenSelectPhoto = false - config.previewView.bottomView.editButtonHidden = true - config.photoList.allowAddCamera = true - config.photoList.camera.allowsEditing = false - let pickerController = PhotoPickerController(picker: config) - pickerController.pickerDelegate = self - pickerController.modalPresentationStyle = .fullScreen - self.present(pickerController, animated: true, completion: nil) + let picker = PhotoPickerVC(maxCount: 9) + picker.onConfirm = { [weak self] images in + for image in images { + self?.sendImageMessage(image) + } + } + present(picker, animated: true) } private func showCamera() { @@ -616,14 +608,14 @@ final class GroupChatVC: BaseViewController { DLToast.showError(text: "当前设备不支持拍照") return } - let picker = ImagePicker() - picker.imageDidPicked = { [weak self] image in - DispatchQueue.main.async { + let vc = CameraCaptureVC(mode: .photo) + vc.onPhoto = { [weak self] image in + self?.dismiss(animated: true) { self?.sendImageMessage(image) } } - cameraPicker = picker - picker.showCamera() + vc.modalPresentationStyle = .fullScreen + present(vc, animated: true) } private func showLocationPicker() { @@ -898,6 +890,12 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } + cell.onNeedRelayout = { [weak tableView] in + UIView.performWithoutAnimation { + tableView?.beginUpdates() + tableView?.endUpdates() + } + } return cell case let .imageReceived(msg): let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -905,6 +903,12 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } + cell.onNeedRelayout = { [weak tableView] in + UIView.performWithoutAnimation { + tableView?.beginUpdates() + tableView?.endUpdates() + } + } return cell case let .locationSend(msg): let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -999,33 +1003,16 @@ private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener { } } -// MARK: - PhotoPickerControllerDelegate -extension GroupChatVC: PhotoPickerControllerDelegate { - /// 选择完成之后调用 - /// - Parameters: - /// - pickerController: 对应的 PhotoPickerController - /// - result: 选择的结果 - /// result.photoAssets 选择的资源数组 - /// result.isOriginal 是否选中原图 - func pickerController(_ pickerController: PhotoPickerController, - didFinishSelection result: PickerResult) { - result.getImage { (image, photoAsset, index) in - } completionHandler: { [weak self] (images) in - guard let self = self else { return } - for img in images { - self.sendImageMessage(img) - } - } - } - +extension GroupChatVC { private func sendImageMessage(_ image: UIImage) { - guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let sendImage = image.cgImage != nil ? image.fixOrientation() : image + guard let data = sendImage.jpegData(compressionQuality: 0.8) else { return } let dir = NSTemporaryDirectory() let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg" let fileURL = URL(fileURLWithPath: dir + filename) try? data.write(to: fileURL) - let displaySize = Self.imageDisplaySize(w: image.size.width, h: image.size.height) + let displaySize = ChatImageLayout.displaySize(width: sendImage.size.width, height: sendImage.size.height) let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path) // 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重 @@ -1078,21 +1065,6 @@ extension GroupChatVC: PhotoPickerControllerDelegate { } }) } - - private static func imageDisplaySize(w: CGFloat, h: CGFloat) -> CGSize { - guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) } - let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80 - var dw = maxW, dh = dw * (h / w) - if dh > maxH { dh = maxH; dw = dh * (w / h) } - if dw < minW { dw = minW; dh = dw * (h / w) } - return CGSize(width: dw, height: dh) - } - - /// 点击取消时调用 - /// - Parameter pickerController: 对应的 PhotoPickerController - func pickerController(didCancel pickerController: PhotoPickerController) { - - } } // MARK: - 群聊位置选择 diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index c24fceb2..35cd92b1 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -19,6 +19,29 @@ enum VoiceRecordState { case ended(URL?) // nil = cancelled } +/// 聊天图片气泡尺寸:按原图宽高缩放,忽略 snapshot 的正方形裁剪。 +enum ChatImageLayout { + static let maxWidth: CGFloat = 200 + static let maxHeight: CGFloat = 250 + static let minWidth: CGFloat = 80 + static let fallback = CGSize(width: 160, height: 120) + + static func displaySize(width: CGFloat, height: CGFloat) -> CGSize { + guard width > 1, height > 1, width.isFinite, height.isFinite else { return fallback } + var dw = maxWidth + var dh = dw * (height / width) + if dh > maxHeight { + dh = maxHeight + dw = dh * (width / height) + } + if dw < minWidth { + dw = minWidth + dh = dw * (height / width) + } + return CGSize(width: dw.rounded(), height: dh.rounded()) + } +} + // MARK: - Message Model struct ChatMessage { let id: String @@ -578,6 +601,7 @@ class GroupChatView: UIView { tv.register(LocationReceivedMsgCell.self) tv.register(NotificationMsgCell.self) tv.rowHeight = UITableView.automaticDimension + tv.estimatedRowHeight = 160 tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) return tv }() @@ -1983,26 +2007,71 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { } -// MARK: - 发送的图片消息 -final class ImageSendMsgCell: UITableViewCell { +// MARK: - 图片消息(收发共用尺寸,避免接收 cell 再走一套正方形逻辑) +final class ImageSendMsgCell: ChatImageMsgCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: true) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +final class ImageReceivedMsgCell: ChatImageMsgCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: false) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +class ChatImageMsgCell: UITableViewCell { var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? + var onNeedRelayout: (() -> Void)? + + private let isOutgoing: Bool + private var configuredId: String? func configure(_ msg: ChatMessage) { + configuredId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) - if !msg.imageUrl.isEmpty { - if let localImage = UIImage(contentsOfFile: msg.imageUrl) { - photoView.image = localImage - } else { - photoView.dl.setImage(with: msg.imageUrl) - } - } - photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) + applyPhotoSize(ChatImageLayout.displaySize(width: msg.imageWidth, height: msg.imageHeight)) + loadPhoto(url: msg.imageUrl, messageId: msg.id, preferred: CGSize(width: msg.imageWidth, height: msg.imageHeight)) + guard isOutgoing else { return } loadingView.isHidden = !msg.isUploading - if msg.isUploading { loadingView.startAnimating() } + if msg.isUploading { loadingView.startAnimating() } else { loadingView.stopAnimating() } + } + + private func loadPhoto(url: String, messageId: String, preferred: CGSize) { + guard !url.isEmpty else { photoView.image = nil; return } + if let localImage = UIImage(contentsOfFile: url) { + photoView.image = localImage + syncPhotoSizeIfNeeded(localImage.size, preferred: preferred) + return + } + photoView.dl.setImage(with: url) { [weak self] image, _ in + guard let self, self.configuredId == messageId, let image else { return } + self.syncPhotoSizeIfNeeded(image.size, preferred: preferred) + } + } + + private func syncPhotoSizeIfNeeded(_ imageSize: CGSize, preferred: CGSize) { + let size = ChatImageLayout.displaySize(width: imageSize.width, height: imageSize.height) + let current = preferred.width > 1 && preferred.height > 1 + ? ChatImageLayout.displaySize(width: preferred.width, height: preferred.height) + : ChatImageLayout.fallback + guard abs(size.width - current.width) > 2 || abs(size.height - current.height) > 2 else { return } + applyPhotoSize(size) + let id = configuredId + DispatchQueue.main.async { [weak self] in + guard let self, self.configuredId == id else { return } + self.onNeedRelayout?() + } + } + + private func applyPhotoSize(_ size: CGSize) { + photoView.layoutChain.width(size.width).height(size.height) } @objc private func onTap() { onImageTap?() } @@ -2028,8 +2097,6 @@ final class ImageSendMsgCell: UITableViewCell { iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") -// iv.borderWidth = 2 -// iv.borderColor = .white return iv }() @@ -2045,8 +2112,23 @@ final class ImageSendMsgCell: UITableViewCell { return iv }() - override init(style: CellStyle, reuseIdentifier: String?) { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + self.isOutgoing = false super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, isOutgoing: Bool) { + self.isOutgoing = isOutgoing + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) @@ -2056,112 +2138,50 @@ final class ImageSendMsgCell: UITableViewCell { let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) photoView.addGestureRecognizer(tap) - photoView.addSubview(loadingView) - loadingView.layoutChain.centerX().centerY() timeLabel.layoutChain.top().centerX() - - avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 10) - .right(12).width(38).height(38) - senderNameView.translatesAutoresizingMaskIntoConstraints = false - senderNameView.layoutChain - .topToView(avatarView) - .rightToLeftOfView(avatarView, offset: -9) - photoView.layoutChain - .topToBottomOfView(senderNameView, offset: 5) - .rightToView(senderNameView, offset: 0) - .bottom(10) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// MARK: - 收到的图片消息 -final class ImageReceivedMsgCell: UITableViewCell { - var menuAnchorView: UIView { photoView } - var onImageTap: (() -> Void)? - - func configure(_ msg: ChatMessage) { - timeLabel.isHidden = !msg.showTime - timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil - avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) - if !msg.imageUrl.isEmpty { - photoView.dl.setImage(with: msg.imageUrl) + if isOutgoing { + photoView.addSubview(loadingView) + loadingView.layoutChain.centerX().centerY() + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) + photoView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .rightToView(senderNameView, offset: 0) + .width(ChatImageLayout.fallback.width) + .height(ChatImageLayout.fallback.height) + .bottom(10) + } else { + loadingView.isHidden = true + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + photoView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .leftToView(senderNameView) + .width(ChatImageLayout.fallback.width) + .height(ChatImageLayout.fallback.height) + .bottom(10) } - photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) } - @objc private func onTap() { onImageTap?() } - - private let timeLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 12) - label.textColor = UIColor(hexStr: "#999999") - label.textAlignment = .center - return label - }() - - let avatarView: UIImageView = { - let iv = UIImageView() - iv.contentMode = .scaleAspectFill - iv.cornerRadius = 10 - iv.clipsToBounds = true - iv.backgroundColor = UIColor(hexStr: "#E0E0E0") -// iv.borderWidth = 2 -// iv.borderColor = .white - return iv - }() - - private let senderNameView = ChatSenderNameView() - - lazy var photoView: UIImageView = { - let iv = UIImageView() - iv.contentMode = .scaleAspectFill - iv.cornerRadius = 8 - iv.clipsToBounds = true - iv.backgroundColor = UIColor(hexStr: "#F0F0F0") - iv.isUserInteractionEnabled = true - return iv - }() - - override init(style: CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - backgroundColor = .clear - contentView.addSubview(timeLabel) - contentView.addSubview(senderNameView) - contentView.addSubview(photoView) - contentView.addSubview(avatarView) - - let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) - photoView.addGestureRecognizer(tap) - - timeLabel.layoutChain.top().centerX() - - avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 10) - .left(12).width(38).height(38) - - senderNameView.translatesAutoresizingMaskIntoConstraints = false - senderNameView.layoutChain - .topToView(avatarView) - .leftToRightOfView(avatarView, offset: 9) - - photoView.layoutChain - .topToBottomOfView(senderNameView, offset: 5) - .leftToView(senderNameView) - .width(160).height(160) - .bottom(10) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + override func prepareForReuse() { + super.prepareForReuse() + photoView.image = nil + onImageTap = nil + onNeedRelayout = nil + configuredId = nil + loadingView.stopAnimating() + loadingView.isHidden = true } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index 69697150..bc7985bd 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -713,28 +713,15 @@ final class GroupChatViewModel { var isAtAll = false var location: ChatLocationPayload? - let maxWH: CGFloat = 200 - var msgImageW: CGFloat = 0 - var msgImageH: CGFloat = 0 - if let width = msg.pictureElem?.sourcePicture?.width, - let height = msg.pictureElem?.bigPicture?.height { - if width >= height { - msgImageW = maxWH - msgImageH = maxWH * CGFloat(height) / CGFloat(width) - } else { - msgImageH = maxWH - msgImageW = maxWH * CGFloat(width) / CGFloat(height) - } - } - let type = msg.contentType.rawValue if type == 103, let sound = msg.soundElem { content = "\(sound.duration)" voiceUrl = sound.sourceUrl ?? "" } else if type == 102, let pic = msg.pictureElem { - imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" - imageW = msgImageW - imageH = msgImageH + let layout = Self.pictureLayout(from: pic) + imageUrl = layout.url + imageW = layout.size.width + imageH = layout.size.height } else if type == 109, let elem = msg.locationElem { location = ChatLocationPayload.decode(description: elem.desc, latitude: elem.latitude, @@ -757,9 +744,10 @@ final class GroupChatViewModel { content = "\(sound.duration)" voiceUrl = sound.sourceUrl ?? "" } else if let pic = msg.pictureElem { - imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" - imageW = msgImageW - imageH = msgImageH + let layout = Self.pictureLayout(from: pic) + imageUrl = layout.url + imageW = layout.size.width + imageH = layout.size.height } else { content = msg.textElem?.content ?? "" } @@ -790,6 +778,42 @@ final class GroupChatViewModel { ) } + /// 用原图/大图的真实宽高计算气泡。对方消息常只带 snapshot,URL 为空字符串时不能用 `??` 跳过。 + private static func pictureLayout(from pic: OIMPictureElem) -> (url: String, size: CGSize) { + let sourceURL = nonEmpty(pic.sourcePicture?.url) + let bigURL = nonEmpty(pic.bigPicture?.url) + let path = nonEmpty(pic.sourcePath) + let snapshotURL = nonEmpty(pic.snapshotPicture?.url) + + let url = sourceURL ?? bigURL ?? path ?? snapshotURL ?? "" + let usingSnapshotOnly = sourceURL == nil && bigURL == nil && path == nil && snapshotURL != nil + + let sizePairs: [(CGFloat, CGFloat)] + if usingSnapshotOnly { + sizePairs = [(pic.snapshotPicture?.width ?? 0, pic.snapshotPicture?.height ?? 0)] + } else { + sizePairs = [ + (pic.sourcePicture?.width ?? 0, pic.sourcePicture?.height ?? 0), + (pic.bigPicture?.width ?? 0, pic.bigPicture?.height ?? 0) + ] + } + for (w, h) in sizePairs where w > 1 && h > 1 { + return (url, ChatImageLayout.displaySize(width: w, height: h)) + } + if let path, let image = UIImage(contentsOfFile: path), + image.size.width > 1, image.size.height > 1 { + return (url.isEmpty ? path : url, + ChatImageLayout.displaySize(width: image.size.width, height: image.size.height)) + } + return (url, ChatImageLayout.fallback) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview { let id = msg.clientMsgID let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "") diff --git a/QuickLocation/Section/Group/GroupView.swift b/QuickLocation/Section/Group/GroupView.swift index 7b022b78..13dca9c3 100644 --- a/QuickLocation/Section/Group/GroupView.swift +++ b/QuickLocation/Section/Group/GroupView.swift @@ -462,6 +462,7 @@ class GroupView: UIView { tv.showsVerticalScrollIndicator = false tv.register(CircleGroupCell.self) tv.estimatedRowHeight = 70 + tv.rowHeight = 70 tv.bounces = true tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0) return tv @@ -474,6 +475,7 @@ class GroupView: UIView { tv.showsVerticalScrollIndicator = false tv.register(CircleGroupCell.self) tv.estimatedRowHeight = 70 + tv.rowHeight = 70 tv.bounces = true tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0) return tv @@ -650,23 +652,26 @@ final class CircleGroupCell: UITableViewCell { private let nameLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = UIColor(hexStr: "#2A3648") + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return label }() - private let countLabel: UILabel = { + private let previewLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .medium) - label.textColor = UIColor(hexStr: "#999999") + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textColor = UIColor(hexStr: "#AAAAAA") + label.lineBreakMode = .byTruncatingTail return label }() lazy var unreadBadgeView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#FF383C") - view.cornerRadius = 7 + view.cornerRadius = 8 view.isHidden = true + view.clipsToBounds = true return view }() @@ -674,65 +679,75 @@ final class CircleGroupCell: UITableViewCell { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = .white + label.textAlignment = .center return label }() private let timeLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .regular) - label.textColor = UIColor(hexStr: "#999999") + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textColor = UIColor(hexStr: "#AAAAAA") label.textAlignment = .right + label.setContentCompressionResistancePriority(.required, for: .horizontal) return label }() - private let dividerLine: UIView = { - let v = UIView() - v.backgroundColor = UIColor(hexStr: "#F0F0F0") - return v + private let tagStack: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .center + view.spacing = 4 + view.setContentCompressionResistancePriority(.required, for: .horizontal) + return view }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .white + contentView.clipsToBounds = false + clipsToBounds = false contentView.addSubview(iconView) - contentView.addSubview(nameLabel) - contentView.addSubview(countLabel) contentView.addSubview(unreadBadgeView) unreadBadgeView.addSubview(unreadBadge) + contentView.addSubview(nameLabel) contentView.addSubview(timeLabel) - contentView.addSubview(dividerLine) + contentView.addSubview(previewLabel) + contentView.addSubview(tagStack) iconView.layoutChain - .left(15).edgesVertical(15) - .width(40).height(40) - - nameLabel.layoutChain - .topToView(iconView, offset: 4) - .leftToRightOfView(iconView, offset: 12) - .right(90) - - countLabel.layoutChain - .topToBottomOfView(nameLabel, offset: 4) - .leftToView(nameLabel) + .left(15) + .centerY() + .width(42) + .height(42) unreadBadgeView.layoutChain - .centerY(nameLabel) - .right(15) + .topToView(iconView, offset: -4) + .rightToView(iconView, offset: 6) + .height(16) unreadBadge.layoutChain - .edgesHorzontal(8) - .edgesVertical(1) + .edgesHorzontal(5) + .centerY() timeLabel.layoutChain + .topToView(iconView, offset: 2) .right(15) - .centerY(countLabel) - dividerLine.layoutChain - .edgesHorzontal(15) - .bottom() - .height(0.5) + nameLabel.layoutChain + .topToView(iconView, offset: 2) + .leftToRightOfView(iconView, offset: 12) + .rightToLeftOfView(timeLabel, offset: -8) + + tagStack.layoutChain + .right(15) + .centerY(previewLabel) + + previewLabel.layoutChain + .topToBottomOfView(nameLabel, offset: 4) + .leftToView(nameLabel) + .rightToLeftOfView(tagStack, offset: -8) } required init?(coder: NSCoder) { @@ -742,10 +757,10 @@ final class CircleGroupCell: UITableViewCell { func configure(_ model: GroupCellData) { let g = model.groupInfo iconView.image = model.localIcon - nameLabel.text = g.groupName ?? "" - countLabel.text = "共\(g.memberCount)个成员" + let count = g.memberCount + nameLabel.text = "\(g.groupName ?? "") (\(count))" + previewLabel.text = model.lastMsgPreview - // 未读消息数 if model.unreadCount > 0 { unreadBadgeView.isHidden = false unreadBadge.text = model.unreadCount > 99 ? "99+" : "\(model.unreadCount)" @@ -753,13 +768,48 @@ final class CircleGroupCell: UITableViewCell { unreadBadgeView.isHidden = true } - // 最后消息时间 if model.lastMsgTime > 0 { let date = Date(timeIntervalSince1970: TimeInterval(model.lastMsgTime) / 1000) timeLabel.text = date.formatForIM() } else { timeLabel.text = "" } + + tagStack.arrangedSubviews.forEach { + tagStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + let tags = Array(model.tags.prefix(2)) + tagStack.isHidden = tags.isEmpty + for text in tags { + tagStack.addArrangedSubview(Self.makeTagLabel(text)) + } + } + + private static func makeTagLabel(_ text: String) -> UILabel { + let label = CircleGroupTagLabel() + label.text = text + label.textColor = UIColor(hexStr: "#00ADFE") + label.font = .systemFont(ofSize: 10, weight: .medium) + label.backgroundColor = UIColor(hexStr: "#E3F6FF") + label.cornerRadius = 4 + label.clipsToBounds = true + label.textAlignment = .center + return label + } +} + +private final class CircleGroupTagLabel: UILabel { + private let inset = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: inset)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + inset.left + inset.right, + height: size.height + inset.top + inset.bottom) } } diff --git a/QuickLocation/Section/Group/GroupViewModel.swift b/QuickLocation/Section/Group/GroupViewModel.swift index bf94a550..ac5affd1 100644 --- a/QuickLocation/Section/Group/GroupViewModel.swift +++ b/QuickLocation/Section/Group/GroupViewModel.swift @@ -18,6 +18,8 @@ struct GroupCellData { let groupInfo: OIMGroupInfo let unreadCount: Int let lastMsgTime: Int // ms timestamp + let lastMsgPreview: String + let tags: [String] let localIcon: UIImage } @@ -96,6 +98,8 @@ final class GroupViewModel { groupInfo: g, unreadCount: Int(conv?.unreadCount ?? 0), lastMsgTime: Int(conv?.latestMsgSendTime ?? 0), + lastMsgPreview: Self.previewText(from: conv?.latestMsg), + tags: Array((groupList.first { $0.group_key == g.groupID }?.labels ?? []).prefix(2)), localIcon: getLocalIcon(groupId: g.groupID ?? "") ) all.append(data) @@ -108,6 +112,32 @@ final class GroupViewModel { joinedSectionsSubject.onNext(all.mapSection()) } + private static func previewText(from msg: OIMMessageInfo?) -> String { + guard let msg else { return "" } + let body: String + switch msg.contentType { + case .image: body = "[图片]" + case .audio: body = "[语音]" + case .video: body = "[视频]" + case .file: body = "[文件]" + case .location: body = "[位置]" + case .at: body = Self.displayBody(msg.atTextElem?.text ?? "[消息]") + case .quote: body = Self.displayBody(msg.quoteElem?.text ?? "[消息]") + case .face: body = "[表情]" + default: + body = Self.displayBody(msg.textElem?.content ?? msg.content ?? "") + } + let name = msg.senderNickname ?? "" + if name.isEmpty { return body } + if body.isEmpty { return name } + return "\(name): \(body)" + } + + private static func displayBody(_ text: String) -> String { + if text.hasPrefix("js_emoji:") { return "[表情]" } + return text + } + // MARK: - Init init() { output = Output( diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 82e11ce4..2bf96287 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -1255,6 +1255,26 @@ extension HomeViewController { if let view = mapView.view(for: selectedAnn) { raiseAnnotationView(view) } + refreshAnnotationMoods() + } + + private func moodIndex(for member: CircleMember) -> Int { + if member.isCurrentUser { + return AppContextManager.shared.account?.mood ?? 0 + } + return viewModel.memberList.first(where: { $0.user_id == member.id })?.mood ?? 0 + } + + private func refreshAnnotationMoods() { + guard let mapView = rootView.mapView else { return } + for annotation in mapView.annotations ?? [] { + guard let memberAnn = annotation as? MemberAnnotation, + let view = mapView.view(for: memberAnn) as? MemberAnnotationView else { continue } + view.setMood( + index: moodIndex(for: memberAnn.member), + selected: isSelectedAnnotation(memberAnn, userId: selectedMemberId) + ) + } } #endif /// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id @@ -1390,7 +1410,15 @@ extension HomeViewController: MAMapViewDelegate { annotationView?.annotation = memberAnnotation } + let selectedIds = annotationIds(matching: selectedMemberId) + let isSelected = selectedIds.contains(memberAnnotation.member.id) + || (memberAnnotation.member.isCurrentUser + && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) annotationView?.configure(with: memberAnnotation.member) + annotationView?.setMood( + index: moodIndex(for: memberAnnotation.member), + selected: isSelected + ) annotationView?.onTap = { [weak self] ann in self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) } @@ -1399,9 +1427,7 @@ extension HomeViewController: MAMapViewDelegate { if memberAnnotation.member.isCurrentUser { annotationView?.updateHeading(currentHeading) } - let selectedIds = annotationIds(matching: selectedMemberId) - if selectedIds.contains(memberAnnotation.member.id) - || (memberAnnotation.member.isCurrentUser && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) { + if isSelected { annotationView?.zIndex = 10 } else if memberAnnotation.member.isCurrentUser { annotationView?.zIndex = 1 diff --git a/QuickLocation/Section/Map/MemberAnnotationView.swift b/QuickLocation/Section/Map/MemberAnnotationView.swift index 00573284..a3fee5ef 100644 --- a/QuickLocation/Section/Map/MemberAnnotationView.swift +++ b/QuickLocation/Section/Map/MemberAnnotationView.swift @@ -6,6 +6,7 @@ import Foundation #if !targetEnvironment(simulator) import AMapNaviKit +import Lottie final class MemberAnnotationView: MAAnnotationView { @@ -13,7 +14,7 @@ final class MemberAnnotationView: MAAnnotationView { var onTap: ((MemberAnnotation) -> Void)? // MARK: - Design Constants - private static let bubbleSize: CGFloat = 56 + private static let bubbleSize: CGFloat = 66 private static let bubbleCorner: CGFloat = 14 private static let borderWidth: CGFloat = 4 private static let pointerWidth: CGFloat = 14 @@ -22,8 +23,10 @@ final class MemberAnnotationView: MAAnnotationView { private static let gapNameToBubble: CGFloat = 4 private static let nameTagHeight: CGFloat = 20 private static let nameTagHPadding: CGFloat = 8 - private static let dotOuterSize: CGFloat = 12 - private static let dotInnerSize: CGFloat = 10 + private static let moodSize: CGFloat = 36 + private static let gapMoodToName: CGFloat = 2 + private static let dotOuterSize: CGFloat = 15 + private static let dotInnerSize: CGFloat = 12 private static let currentUserDotColor = UIColor(hexStr: "#5ED4FF") private static let memberDotPalette: [UIColor] = [ @@ -42,9 +45,22 @@ final class MemberAnnotationView: MAAnnotationView { ] private var showsNameTag = false + private var showsMood = false + private var playingMoodName: String? + private var finishedMoodName: String? private let containerView = UIView() + private let moodView: LottieAnimationView = { + let view = LottieAnimationView() + view.contentMode = .scaleAspectFit + view.loopMode = .playOnce + view.backgroundBehavior = .pauseAndRestore + view.isHidden = true + view.isUserInteractionEnabled = false + return view + }() + private let nameTagView: UIView = { let v = UIView() v.backgroundColor = .white @@ -113,7 +129,7 @@ final class MemberAnnotationView: MAAnnotationView { private let dotCenterView: UIView = { let v = UIView() v.backgroundColor = .white - v.layer.cornerRadius = 2 + v.layer.cornerRadius = 3 return v }() @@ -136,6 +152,7 @@ final class MemberAnnotationView: MAAnnotationView { addSubview(containerView) containerView.isUserInteractionEnabled = true + containerView.addSubview(moodView) containerView.addSubview(nameTagView) nameTagView.addSubview(nameLabel) containerView.addSubview(bubbleView) @@ -174,12 +191,53 @@ final class MemberAnnotationView: MAAnnotationView { layoutMarker() } + func setMood(index: Int, selected: Bool) { + let item = selected ? MoodStatusCatalog.item(moodIndex: index) : nil + guard let item, let path = item.jsonPath else { + stopMood() + finishedMoodName = nil + layoutMarker() + return + } + if finishedMoodName == item.jsonName { + layoutMarker() + return + } + if playingMoodName == item.jsonName, moodView.animation != nil { + layoutMarker() + return + } + showsMood = true + moodView.isHidden = false + playingMoodName = item.jsonName + moodView.stop() + moodView.animation = LottieAnimation.filepath(path) + moodView.currentProgress = 0 + moodView.play { [weak self] completed in + guard let self, completed else { return } + self.finishedMoodName = item.jsonName + self.stopMood() + self.layoutMarker() + } + layoutMarker() + } + + private func stopMood() { + showsMood = false + playingMoodName = nil + moodView.stop() + moodView.animation = nil + moodView.isHidden = true + } + override func prepareForReuse() { super.prepareForReuse() showsNameTag = false nameTagView.isHidden = true nameLabel.text = nil onTap = nil + finishedMoodName = nil + stopMood() layoutMarker() } @@ -203,8 +261,10 @@ final class MemberAnnotationView: MAAnnotationView { let gap = Self.gapBubbleToDot let dot = Self.dotOuterSize + let moodH: CGFloat = showsMood ? Self.moodSize : 0 + let moodGap: CGFloat = showsMood ? Self.gapMoodToName : 0 let nameH: CGFloat = showsNameTag ? Self.nameTagHeight : 0 - let nameGap: CGFloat = showsNameTag ? Self.gapNameToBubble : 0 + let nameGap: CGFloat = showsNameTag ? Self.gapNameToBubble : (showsMood ? 2 : 0) var nameTagWidth: CGFloat = 0 if showsNameTag { @@ -213,24 +273,33 @@ final class MemberAnnotationView: MAAnnotationView { nameTagWidth = min(max(textWidth + Self.nameTagHPadding * 2, 44), 120) } - let totalWidth = max(bubble, nameTagWidth) - let totalHeight = nameH + nameGap + bubble + pointerH + gap + dot + let totalWidth = max(bubble, nameTagWidth, showsMood ? Self.moodSize : 0) + let totalHeight = moodH + moodGap + nameH + nameGap + bubble + pointerH + gap + dot bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight) containerView.frame = bounds centerOffset = CGPoint(x: 0, y: -(totalHeight / 2 - Self.dotOuterSize / 2)) + if showsMood { + moodView.frame = CGRect( + x: (totalWidth - Self.moodSize) / 2, + y: 0, + width: Self.moodSize, + height: Self.moodSize + ) + } + if showsNameTag { nameTagView.frame = CGRect( x: (totalWidth - nameTagWidth) / 2, - y: 0, + y: moodH + moodGap, width: nameTagWidth, height: Self.nameTagHeight ) nameLabel.frame = nameTagView.bounds.insetBy(dx: Self.nameTagHPadding - 2, dy: 0) } - let bubbleY = nameH + nameGap + let bubbleY = moodH + moodGap + nameH + nameGap bubbleView.frame = CGRect( x: (totalWidth - bubble) / 2, y: bubbleY, @@ -262,7 +331,7 @@ final class MemberAnnotationView: MAAnnotationView { x: inset, y: inset, width: Self.dotInnerSize, height: Self.dotInnerSize ) - let centerSize: CGFloat = 4 + let centerSize: CGFloat = 6 let centerInset = (Self.dotInnerSize - centerSize) / 2 dotCenterView.frame = CGRect( x: centerInset, y: centerInset, diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift index 458eba10..88694913 100644 --- a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift +++ b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift @@ -6,7 +6,7 @@ import UIKit import RxSwift import RxCocoa -import HXPHPicker + final class FeedbackVC: BaseViewController { @@ -98,19 +98,14 @@ final class FeedbackVC: BaseViewController { private func showAlbum() { let remainingCount = 3 - selectedImages.count guard remainingCount > 0, !isSubmitting else { return } - - let config = PhotoTools.getWXPickerConfig() - config.selectOptions = [.photo] - config.selectMode = .multiple - config.maximumSelectedCount = remainingCount - config.allowSyncICloudWhenSelectPhoto = false - config.previewView.bottomView.editButtonHidden = true - config.photoList.allowAddCamera = false - - let pickerController = PhotoPickerController(picker: config) - pickerController.pickerDelegate = self - pickerController.modalPresentationStyle = .fullScreen - present(pickerController, animated: true) + let picker = PhotoPickerVC(maxCount: remainingCount) + picker.onConfirm = { [weak self] images in + guard let self else { return } + self.selectedImages.append(contentsOf: images.prefix(remainingCount)) + self.selectedImages = Array(self.selectedImages.prefix(3)) + self.rootView.updateImages(self.selectedImages) + } + present(picker, animated: true) } private func submit() { @@ -144,25 +139,4 @@ final class FeedbackVC: BaseViewController { } } -extension FeedbackVC: PhotoPickerControllerDelegate { - func pickerController( - _ pickerController: PhotoPickerController, - didFinishSelection result: PickerResult - ) { - let remainingCount = max(0, 3 - selectedImages.count) - guard remainingCount > 0 else { return } - - result.getImage { _, _, _ in - } completionHandler: { [weak self] images in - DispatchQueue.main.async { - guard let self else { return } - self.selectedImages.append(contentsOf: images.prefix(remainingCount)) - self.selectedImages = Array(self.selectedImages.prefix(3)) - self.rootView.updateImages(self.selectedImages) - } - } - } - - func pickerController(didCancel pickerController: PhotoPickerController) {} -} diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index eeebba16..7396890b 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -24,17 +24,6 @@ final class PigeonMessageVC: BaseViewController { private var recordedDuration: TimeInterval = 0 private var isPressingRecord = false - private lazy var imagePicker: ImagePicker = { - let picker = ImagePicker() - picker.imageDidPicked = { [weak self] image in - DispatchQueue.main.async { - self?.selectedImage = image - self?.rootView.setSelectedImage(image) - } - } - return picker - }() - override func loadView() { rootView = PigeonMessageView(frame: UIScreen.main.bounds) view = rootView @@ -144,7 +133,13 @@ final class PigeonMessageVC: BaseViewController { } @objc private func selectImage() { - imagePicker.presentImagePickerPopup() + let picker = PhotoPickerVC(maxCount: 1) + picker.onConfirm = { [weak self] images in + guard let image = images.first else { return } + self?.selectedImage = image + self?.rootView.setSelectedImage(image) + } + present(picker, animated: true) } @objc private func deleteImage() { diff --git a/QuickLocation/Section/Scan/ScanVC.swift b/QuickLocation/Section/Scan/ScanVC.swift index a0ce7d6e..bcc62163 100644 --- a/QuickLocation/Section/Scan/ScanVC.swift +++ b/QuickLocation/Section/Scan/ScanVC.swift @@ -2,8 +2,6 @@ // ScanVC.swift // QuickLocation // -// Created by 八条 on 2026/6/2. -// import UIKit import AVFoundation @@ -12,48 +10,41 @@ import RxSwift class ScanVC: BaseViewController { override var isNavigationBarHidden: Bool { true } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } - fileprivate var rootView: ScanView! + private var rootView: CameraCaptureView! + private let scanDoneAction: ((String) -> Void)? + private var isHandling = false override func loadView() { - rootView = ScanView(frame: UIScreen.main.bounds) + rootView = CameraCaptureView(mode: .scan) view = rootView } - private let scanDoneAction: ((String) -> Void)? - - private var captureSession: AVCaptureSession! - private var previewLayer: AVCaptureVideoPreviewLayer! - private var isScanning = false - override func viewDidLoad() { super.viewDidLoad() - setupCamera() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.frame = view.bounds + rootView.onClose = { [weak self] in + self?.closeCapture() + } + rootView.onScan = { [weak self] text in + self?.handleScanResult(text: text) + } + rootView.onOpenAlbum = { [weak self] in + self?.openAlbum() + } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if let session = captureSession, !session.isRunning { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - startScanAnimation() + isHandling = false + rootView.startSession() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if let session = captureSession, session.isRunning { - session.stopRunning() - } - rootView.scanLineView.layer.removeAllAnimations() + rootView.stopSession() } - + init(scanDoneAction: ((String) -> Void)? = nil) { self.scanDoneAction = scanDoneAction super.init(nibName: nil, bundle: nil) @@ -63,123 +54,75 @@ class ScanVC: BaseViewController { fatalError("init(coder:) has not been implemented") } - private func handleScanResult(text: String) { - guard !isScanning else { return } - isScanning = true - captureSession.stopRunning() - rootView.scanLineView.alpha = 0 - rootView.scanLineView.layer.removeAllAnimations() - if let scanDoneAction = scanDoneAction { - scanDoneAction(text) - AppRouter.shared.popOrDismiss() + private func closeCapture() { + if presentingViewController != nil { + dismiss(animated: true) + } else { + navigationController?.popViewController(animated: true) } -// requestJoinGroup(code: text) } - - // MARK: - API + + private func handleScanResult(text: String) { + guard !isHandling else { return } + isHandling = true + rootView.stopSession() + if let scanDoneAction { + scanDoneAction(text) + closeCapture() + return + } + requestJoinGroup(code: text) + } + private func requestJoinGroup(code: String) { - dl.showLoading() - GroupService.operate(opType: "join", requestData: ["share_code" : code]).subscribe(onNext: { response in - self.dl.show(text: "申请成功") { + DLToast.showLoading() + GroupService.operate(opType: "join", requestData: ["share_code": code]).subscribe(onNext: { response in + DLToast.showSuccess(text: response.message ?? "申请成功") { AppRouter.shared.popOrDismiss() } - }, onError: { [weak self] (error) in - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self?.isScanning = false - self?.startSession() + }, onError: { [weak self] _ in + DLToast.showError(text: "无法识别该二维码") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self?.isHandling = false + self?.rootView.startSession() } }).disposed(by: disposeBag) } - - private func startSession() { - if let session = captureSession, !session.isRunning { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - startScanAnimation() + + private func openAlbum() { + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { return } + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.delegate = self + present(picker, animated: true) } - // MARK: - 扫描线动画 - private func startScanAnimation() { - rootView.scanLineView.layer.removeAllAnimations() - rootView.scanLineView.alpha = 1 - rootView.scanLineView.frame.origin.y = 0 - - let boxH = rootView.scanBoxView.bounds.height - guard boxH > 0 else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.startScanAnimation() - } + private func decodeQR(from image: UIImage) { + guard let ciImage = CIImage(image: image) else { + DLToast.show(text: "无法识别二维码") + isHandling = false return } - - UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat, .curveLinear]) { - self.rootView.scanLineView.frame.origin.y = boxH - 2 - } - } - - // MARK: - 相机配置 - private func setupCamera() { - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .authorized: - break - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - DispatchQueue.main.async { - if granted { self?.setupCamera() } - } - } - return - default: - DLToast.show(text: "请在设置中开启相机权限") - return - } - - captureSession = AVCaptureSession() - captureSession.sessionPreset = .high - - guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { - DLToast.show(text: "未检测到摄像头") - return - } - - do { - let input = try AVCaptureDeviceInput(device: videoDevice) - if captureSession.canAddInput(input) { - captureSession.addInput(input) - } - - let output = AVCaptureMetadataOutput() - if captureSession.canAddOutput(output) { - captureSession.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) - output.metadataObjectTypes = [.qr, .code128, .ean13, .ean8, .upce, .code39, .code39Mod43] - } - - previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - previewLayer.frame = view.bounds - previewLayer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(previewLayer, at: 0) - - DispatchQueue.global(qos: .background).async { - self.captureSession.startRunning() - } - - } catch { - DLToast.show(text: "相机初始化失败:\(error.localizedDescription)") + let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + let features = detector?.features(in: ciImage) ?? [] + if let qr = features.first as? CIQRCodeFeature, let text = qr.messageString, !text.isEmpty { + handleScanResult(text: text) + } else { + DLToast.show(text: "未识别到二维码") + isHandling = false } } } -// MARK: - AVCaptureMetadataOutputObjectsDelegate -extension ScanVC: AVCaptureMetadataOutputObjectsDelegate { - func metadataOutput(_ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection) { - guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - let result = obj.stringValue else { return } - handleScanResult(text: result) +extension ScanVC: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true) + if let image = info[.originalImage] as? UIImage { + decodeQR(from: image) + } } }