|
|
@ -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 = "<group>"; };
|
||||
3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanVC.swift; sourceTree = "<group>"; };
|
||||
3062E8B62FCE6BFE00CEF511 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
|
||||
30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureView.swift; sourceTree = "<group>"; };
|
||||
30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureVC.swift; sourceTree = "<group>"; };
|
||||
30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerStore.swift; sourceTree = "<group>"; };
|
||||
30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerView.swift; sourceTree = "<group>"; };
|
||||
30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerAlbumView.swift; sourceTree = "<group>"; };
|
||||
30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerPreviewVC.swift; sourceTree = "<group>"; };
|
||||
30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerVC.swift; sourceTree = "<group>"; };
|
||||
3062E8B92FCEAC6500CEF511 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = "<group>"; };
|
||||
3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupVC.swift; sourceTree = "<group>"; };
|
||||
3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupIconListVC.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -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 = "<group>";
|
||||
};
|
||||
30A1C0A42FEB000100CA0001 /* CameraCapture */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
30A1C0A02FEB000100CA0001 /* CameraCaptureView.swift */,
|
||||
30A1C0A22FEB000100CA0001 /* CameraCaptureVC.swift */,
|
||||
);
|
||||
path = CameraCapture;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
30B2D0BA2FEB111100CA0001 /* PhotoPicker */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
30B2D0B02FEB111100CA0001 /* PhotoPickerStore.swift */,
|
||||
30B2D0B22FEB111100CA0001 /* PhotoPickerView.swift */,
|
||||
30B2D0B42FEB111100CA0001 /* PhotoPickerAlbumView.swift */,
|
||||
30B2D0B62FEB111100CA0001 /* PhotoPickerPreviewVC.swift */,
|
||||
30B2D0B82FEB111100CA0001 /* PhotoPickerVC.swift */,
|
||||
);
|
||||
path = PhotoPicker;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
||||
8
QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/Contents.json
vendored
Normal file
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 556 B |
BIN
QuickLocation/Assets.xcassets/PhotoPicker/btn_album_arrow.imageset/btn_album_arrow@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 609 B |
BIN
QuickLocation/Assets.xcassets/PhotoPicker/btn_close.imageset/btn_close@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = ["最近删除", "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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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: - 群聊位置选择
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
senderNameView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
if isOutgoing {
|
||||
photoView.addSubview(loadingView)
|
||||
loadingView.layoutChain.centerX().centerY()
|
||||
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)
|
||||
.width(ChatImageLayout.fallback.width)
|
||||
.height(ChatImageLayout.fallback.height)
|
||||
.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)
|
||||
}
|
||||
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()
|
||||
|
||||
} else {
|
||||
loadingView.isHidden = true
|
||||
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)
|
||||
.width(ChatImageLayout.fallback.width)
|
||||
.height(ChatImageLayout.fallback.height)
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
// ScanVC.swift
|
||||
// QuickLocation
|
||||
//
|
||||
// Created by 八条 on 2026/6/2.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
|
@ -12,46 +10,39 @@ 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()
|
||||
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 viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -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()
|
||||
DLToast.showLoading()
|
||||
GroupService.operate(opType: "join", requestData: ["share_code": code]).subscribe(onNext: { response in
|
||||
self.dl.show(text: "申请成功") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||