|
|
@ -162,6 +162,13 @@
|
||||||
305A79902FCAC61A00227D26 /* InviteMemberVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A798F2FCAC61A00227D26 /* InviteMemberVC.swift */; };
|
305A79902FCAC61A00227D26 /* InviteMemberVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A798F2FCAC61A00227D26 /* InviteMemberVC.swift */; };
|
||||||
3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */; };
|
3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B42FCE6BBA00CEF511 /* ScanVC.swift */; };
|
||||||
3062E8B72FCE6BFE00CEF511 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B62FCE6BFE00CEF511 /* ScanView.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 */; };
|
3062E8BA2FCEAC6500CEF511 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8B92FCEAC6500CEF511 /* CreateGroupView.swift */; };
|
||||||
3062E8BC2FCEAC7100CEF511 /* CreateGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */; };
|
3062E8BC2FCEAC7100CEF511 /* CreateGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BB2FCEAC7100CEF511 /* CreateGroupVC.swift */; };
|
||||||
3062E8BE2FCEBD0E00CEF511 /* GroupIconListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
3062E8BD2FCEBD0E00CEF511 /* GroupIconListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupIconListVC.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -963,6 +977,8 @@
|
||||||
305A74E72FCA8C7000227D26 /* Component */ = {
|
305A74E72FCA8C7000227D26 /* Component */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
30A1C0A42FEB000100CA0001 /* CameraCapture */,
|
||||||
|
30B2D0BA2FEB111100CA0001 /* PhotoPicker */,
|
||||||
305A74D72FCA8C7000227D26 /* AddImageCell */,
|
305A74D72FCA8C7000227D26 /* AddImageCell */,
|
||||||
305A74DB2FCA8C7000227D26 /* CornerRadiusView */,
|
305A74DB2FCA8C7000227D26 /* CornerRadiusView */,
|
||||||
305A74E22FCA8C7000227D26 /* ImagePicker */,
|
305A74E22FCA8C7000227D26 /* ImagePicker */,
|
||||||
|
|
@ -971,6 +987,27 @@
|
||||||
path = Component;
|
path = Component;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
305A74EA2FCA8C7000227D26 /* RTL */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -2538,6 +2575,13 @@
|
||||||
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */,
|
305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */,
|
||||||
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */,
|
305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */,
|
||||||
3062E8B52FCE6BBA00CEF511 /* ScanVC.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 */,
|
305A76E72FCA8C7000227D26 /* LoginView.swift in Sources */,
|
||||||
305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */,
|
305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */,
|
||||||
30EFF3DC2FDA981B00EB35D4 /* EmergencyContactAddView.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 AVFoundation
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import Speech
|
import Speech
|
||||||
import HXPHPicker
|
|
||||||
import IQKeyboardManagerSwift
|
import IQKeyboardManagerSwift
|
||||||
import MJRefresh
|
import MJRefresh
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
@ -33,7 +33,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
/// mention 列表:首行 @所有人 + 成员
|
/// mention 列表:首行 @所有人 + 成员
|
||||||
private var mentionRows: [(userId: String?, nickname: String)] = []
|
private var mentionRows: [(userId: String?, nickname: String)] = []
|
||||||
private var isMentionPickerVisible = false
|
private var isMentionPickerVisible = false
|
||||||
private var cameraPicker: ImagePicker?
|
|
||||||
/// 加载更早历史前记录,用于保持阅读位置
|
/// 加载更早历史前记录,用于保持阅读位置
|
||||||
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
||||||
private var didSetupKeyboard = false
|
private var didSetupKeyboard = false
|
||||||
|
|
@ -594,21 +594,13 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
// MARK: - 显示相册
|
// MARK: - 显示相册
|
||||||
private func showAlbum() {
|
private func showAlbum() {
|
||||||
// 设置与微信主题一致的配置
|
let picker = PhotoPickerVC(maxCount: 9)
|
||||||
let config = PhotoTools.getWXPickerConfig()
|
picker.onConfirm = { [weak self] images in
|
||||||
// 最多可以选择的资源数,如果为0则不限制
|
for image in images {
|
||||||
config.selectOptions = [.photo]
|
self?.sendImageMessage(image)
|
||||||
config.selectMode = .multiple
|
}
|
||||||
config.maximumSelectedCount = 9
|
}
|
||||||
// config.maximumSelectedPhotoFileSize = 5242880
|
present(picker, animated: true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showCamera() {
|
private func showCamera() {
|
||||||
|
|
@ -616,14 +608,14 @@ final class GroupChatVC: BaseViewController {
|
||||||
DLToast.showError(text: "当前设备不支持拍照")
|
DLToast.showError(text: "当前设备不支持拍照")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let picker = ImagePicker()
|
let vc = CameraCaptureVC(mode: .photo)
|
||||||
picker.imageDidPicked = { [weak self] image in
|
vc.onPhoto = { [weak self] image in
|
||||||
DispatchQueue.main.async {
|
self?.dismiss(animated: true) {
|
||||||
self?.sendImageMessage(image)
|
self?.sendImageMessage(image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cameraPicker = picker
|
vc.modalPresentationStyle = .fullScreen
|
||||||
picker.showCamera()
|
present(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showLocationPicker() {
|
private func showLocationPicker() {
|
||||||
|
|
@ -898,6 +890,12 @@ final class GroupChatVC: BaseViewController {
|
||||||
cell.onImageTap = { [weak self] in
|
cell.onImageTap = { [weak self] in
|
||||||
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
||||||
}
|
}
|
||||||
|
cell.onNeedRelayout = { [weak tableView] in
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView?.beginUpdates()
|
||||||
|
tableView?.endUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
case let .imageReceived(msg):
|
case let .imageReceived(msg):
|
||||||
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
|
|
@ -905,6 +903,12 @@ final class GroupChatVC: BaseViewController {
|
||||||
cell.onImageTap = { [weak self] in
|
cell.onImageTap = { [weak self] in
|
||||||
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
||||||
}
|
}
|
||||||
|
cell.onNeedRelayout = { [weak tableView] in
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
tableView?.beginUpdates()
|
||||||
|
tableView?.endUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
case let .locationSend(msg):
|
case let .locationSend(msg):
|
||||||
let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
|
|
@ -999,33 +1003,16 @@ private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PhotoPickerControllerDelegate
|
extension GroupChatVC {
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendImageMessage(_ image: UIImage) {
|
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 dir = NSTemporaryDirectory()
|
||||||
let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg"
|
let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||||
let fileURL = URL(fileURLWithPath: dir + filename)
|
let fileURL = URL(fileURLWithPath: dir + filename)
|
||||||
try? data.write(to: fileURL)
|
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)
|
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
|
||||||
// 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重
|
// 使用 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: - 群聊位置选择
|
// MARK: - 群聊位置选择
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,29 @@ enum VoiceRecordState {
|
||||||
case ended(URL?) // nil = cancelled
|
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
|
// MARK: - Message Model
|
||||||
struct ChatMessage {
|
struct ChatMessage {
|
||||||
let id: String
|
let id: String
|
||||||
|
|
@ -578,6 +601,7 @@ class GroupChatView: UIView {
|
||||||
tv.register(LocationReceivedMsgCell.self)
|
tv.register(LocationReceivedMsgCell.self)
|
||||||
tv.register(NotificationMsgCell.self)
|
tv.register(NotificationMsgCell.self)
|
||||||
tv.rowHeight = UITableView.automaticDimension
|
tv.rowHeight = UITableView.automaticDimension
|
||||||
|
tv.estimatedRowHeight = 160
|
||||||
tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
|
tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
|
||||||
return tv
|
return tv
|
||||||
}()
|
}()
|
||||||
|
|
@ -1983,26 +2007,71 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - 发送的图片消息
|
// MARK: - 图片消息(收发共用尺寸,避免接收 cell 再走一套正方形逻辑)
|
||||||
final class ImageSendMsgCell: UITableViewCell {
|
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 menuAnchorView: UIView { photoView }
|
||||||
var onImageTap: (() -> Void)?
|
var onImageTap: (() -> Void)?
|
||||||
|
var onNeedRelayout: (() -> Void)?
|
||||||
|
|
||||||
|
private let isOutgoing: Bool
|
||||||
|
private var configuredId: String?
|
||||||
|
|
||||||
func configure(_ msg: ChatMessage) {
|
func configure(_ msg: ChatMessage) {
|
||||||
|
configuredId = msg.id
|
||||||
timeLabel.isHidden = !msg.showTime
|
timeLabel.isHidden = !msg.showTime
|
||||||
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
|
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
|
||||||
avatarView.image = msg.avatar
|
avatarView.image = msg.avatar
|
||||||
senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner)
|
senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner)
|
||||||
if !msg.imageUrl.isEmpty {
|
applyPhotoSize(ChatImageLayout.displaySize(width: msg.imageWidth, height: msg.imageHeight))
|
||||||
if let localImage = UIImage(contentsOfFile: msg.imageUrl) {
|
loadPhoto(url: msg.imageUrl, messageId: msg.id, preferred: CGSize(width: msg.imageWidth, height: msg.imageHeight))
|
||||||
photoView.image = localImage
|
guard isOutgoing else { return }
|
||||||
} else {
|
|
||||||
photoView.dl.setImage(with: msg.imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
|
|
||||||
loadingView.isHidden = !msg.isUploading
|
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?() }
|
@objc private func onTap() { onImageTap?() }
|
||||||
|
|
@ -2028,8 +2097,6 @@ final class ImageSendMsgCell: UITableViewCell {
|
||||||
iv.cornerRadius = 10
|
iv.cornerRadius = 10
|
||||||
iv.clipsToBounds = true
|
iv.clipsToBounds = true
|
||||||
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
||||||
// iv.borderWidth = 2
|
|
||||||
// iv.borderColor = .white
|
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -2045,8 +2112,23 @@ final class ImageSendMsgCell: UITableViewCell {
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
self.isOutgoing = false
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
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
|
selectionStyle = .none
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
contentView.addSubview(timeLabel)
|
contentView.addSubview(timeLabel)
|
||||||
|
|
@ -2056,112 +2138,50 @@ final class ImageSendMsgCell: UITableViewCell {
|
||||||
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
|
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
|
||||||
photoView.addGestureRecognizer(tap)
|
photoView.addGestureRecognizer(tap)
|
||||||
photoView.addSubview(loadingView)
|
|
||||||
loadingView.layoutChain.centerX().centerY()
|
|
||||||
|
|
||||||
timeLabel.layoutChain.top().centerX()
|
timeLabel.layoutChain.top().centerX()
|
||||||
|
|
||||||
avatarView.layoutChain
|
|
||||||
.topToBottomOfView(timeLabel, offset: 10)
|
|
||||||
.right(12).width(38).height(38)
|
|
||||||
|
|
||||||
senderNameView.translatesAutoresizingMaskIntoConstraints = false
|
senderNameView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
senderNameView.layoutChain
|
|
||||||
.topToView(avatarView)
|
|
||||||
.rightToLeftOfView(avatarView, offset: -9)
|
|
||||||
|
|
||||||
photoView.layoutChain
|
if isOutgoing {
|
||||||
.topToBottomOfView(senderNameView, offset: 5)
|
photoView.addSubview(loadingView)
|
||||||
.rightToView(senderNameView, offset: 0)
|
loadingView.layoutChain.centerX().centerY()
|
||||||
.bottom(10)
|
avatarView.layoutChain
|
||||||
}
|
.topToBottomOfView(timeLabel, offset: 10)
|
||||||
|
.right(12).width(38).height(38)
|
||||||
required init?(coder: NSCoder) {
|
senderNameView.layoutChain
|
||||||
fatalError("init(coder:) has not been implemented")
|
.topToView(avatarView)
|
||||||
}
|
.rightToLeftOfView(avatarView, offset: -9)
|
||||||
}
|
photoView.layoutChain
|
||||||
|
.topToBottomOfView(senderNameView, offset: 5)
|
||||||
// MARK: - 收到的图片消息
|
.rightToView(senderNameView, offset: 0)
|
||||||
final class ImageReceivedMsgCell: UITableViewCell {
|
.width(ChatImageLayout.fallback.width)
|
||||||
var menuAnchorView: UIView { photoView }
|
.height(ChatImageLayout.fallback.height)
|
||||||
var onImageTap: (() -> Void)?
|
.bottom(10)
|
||||||
|
} else {
|
||||||
func configure(_ msg: ChatMessage) {
|
loadingView.isHidden = true
|
||||||
timeLabel.isHidden = !msg.showTime
|
avatarView.layoutChain
|
||||||
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
|
.topToBottomOfView(timeLabel, offset: 10)
|
||||||
avatarView.image = msg.avatar
|
.left(12).width(38).height(38)
|
||||||
senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner)
|
senderNameView.layoutChain
|
||||||
if !msg.imageUrl.isEmpty {
|
.topToView(avatarView)
|
||||||
photoView.dl.setImage(with: msg.imageUrl)
|
.leftToRightOfView(avatarView, offset: 9)
|
||||||
|
photoView.layoutChain
|
||||||
|
.topToBottomOfView(senderNameView, offset: 5)
|
||||||
|
.leftToView(senderNameView)
|
||||||
|
.width(ChatImageLayout.fallback.width)
|
||||||
|
.height(ChatImageLayout.fallback.height)
|
||||||
|
.bottom(10)
|
||||||
}
|
}
|
||||||
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func onTap() { onImageTap?() }
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
private let timeLabel: UILabel = {
|
photoView.image = nil
|
||||||
let label = UILabel()
|
onImageTap = nil
|
||||||
label.font = .systemFont(ofSize: 12)
|
onNeedRelayout = nil
|
||||||
label.textColor = UIColor(hexStr: "#999999")
|
configuredId = nil
|
||||||
label.textAlignment = .center
|
loadingView.stopAnimating()
|
||||||
return label
|
loadingView.isHidden = true
|
||||||
}()
|
|
||||||
|
|
||||||
let avatarView: UIImageView = {
|
|
||||||
let iv = UIImageView()
|
|
||||||
iv.contentMode = .scaleAspectFill
|
|
||||||
iv.cornerRadius = 10
|
|
||||||
iv.clipsToBounds = true
|
|
||||||
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
|
||||||
// iv.borderWidth = 2
|
|
||||||
// iv.borderColor = .white
|
|
||||||
return iv
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let senderNameView = ChatSenderNameView()
|
|
||||||
|
|
||||||
lazy var photoView: UIImageView = {
|
|
||||||
let iv = UIImageView()
|
|
||||||
iv.contentMode = .scaleAspectFill
|
|
||||||
iv.cornerRadius = 8
|
|
||||||
iv.clipsToBounds = true
|
|
||||||
iv.backgroundColor = UIColor(hexStr: "#F0F0F0")
|
|
||||||
iv.isUserInteractionEnabled = true
|
|
||||||
return iv
|
|
||||||
}()
|
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
||||||
selectionStyle = .none
|
|
||||||
backgroundColor = .clear
|
|
||||||
contentView.addSubview(timeLabel)
|
|
||||||
contentView.addSubview(senderNameView)
|
|
||||||
contentView.addSubview(photoView)
|
|
||||||
contentView.addSubview(avatarView)
|
|
||||||
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
|
|
||||||
photoView.addGestureRecognizer(tap)
|
|
||||||
|
|
||||||
timeLabel.layoutChain.top().centerX()
|
|
||||||
|
|
||||||
avatarView.layoutChain
|
|
||||||
.topToBottomOfView(timeLabel, offset: 10)
|
|
||||||
.left(12).width(38).height(38)
|
|
||||||
|
|
||||||
senderNameView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
senderNameView.layoutChain
|
|
||||||
.topToView(avatarView)
|
|
||||||
.leftToRightOfView(avatarView, offset: 9)
|
|
||||||
|
|
||||||
photoView.layoutChain
|
|
||||||
.topToBottomOfView(senderNameView, offset: 5)
|
|
||||||
.leftToView(senderNameView)
|
|
||||||
.width(160).height(160)
|
|
||||||
.bottom(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -713,28 +713,15 @@ final class GroupChatViewModel {
|
||||||
var isAtAll = false
|
var isAtAll = false
|
||||||
var location: ChatLocationPayload?
|
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
|
let type = msg.contentType.rawValue
|
||||||
if type == 103, let sound = msg.soundElem {
|
if type == 103, let sound = msg.soundElem {
|
||||||
content = "\(sound.duration)"
|
content = "\(sound.duration)"
|
||||||
voiceUrl = sound.sourceUrl ?? ""
|
voiceUrl = sound.sourceUrl ?? ""
|
||||||
} else if type == 102, let pic = msg.pictureElem {
|
} else if type == 102, let pic = msg.pictureElem {
|
||||||
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
|
let layout = Self.pictureLayout(from: pic)
|
||||||
imageW = msgImageW
|
imageUrl = layout.url
|
||||||
imageH = msgImageH
|
imageW = layout.size.width
|
||||||
|
imageH = layout.size.height
|
||||||
} else if type == 109, let elem = msg.locationElem {
|
} else if type == 109, let elem = msg.locationElem {
|
||||||
location = ChatLocationPayload.decode(description: elem.desc,
|
location = ChatLocationPayload.decode(description: elem.desc,
|
||||||
latitude: elem.latitude,
|
latitude: elem.latitude,
|
||||||
|
|
@ -757,9 +744,10 @@ final class GroupChatViewModel {
|
||||||
content = "\(sound.duration)"
|
content = "\(sound.duration)"
|
||||||
voiceUrl = sound.sourceUrl ?? ""
|
voiceUrl = sound.sourceUrl ?? ""
|
||||||
} else if let pic = msg.pictureElem {
|
} else if let pic = msg.pictureElem {
|
||||||
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
|
let layout = Self.pictureLayout(from: pic)
|
||||||
imageW = msgImageW
|
imageUrl = layout.url
|
||||||
imageH = msgImageH
|
imageW = layout.size.width
|
||||||
|
imageH = layout.size.height
|
||||||
} else {
|
} else {
|
||||||
content = msg.textElem?.content ?? ""
|
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 {
|
private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview {
|
||||||
let id = msg.clientMsgID
|
let id = msg.clientMsgID
|
||||||
let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "")
|
let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "")
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,7 @@ class GroupView: UIView {
|
||||||
tv.showsVerticalScrollIndicator = false
|
tv.showsVerticalScrollIndicator = false
|
||||||
tv.register(CircleGroupCell.self)
|
tv.register(CircleGroupCell.self)
|
||||||
tv.estimatedRowHeight = 70
|
tv.estimatedRowHeight = 70
|
||||||
|
tv.rowHeight = 70
|
||||||
tv.bounces = true
|
tv.bounces = true
|
||||||
tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0)
|
tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0)
|
||||||
return tv
|
return tv
|
||||||
|
|
@ -474,6 +475,7 @@ class GroupView: UIView {
|
||||||
tv.showsVerticalScrollIndicator = false
|
tv.showsVerticalScrollIndicator = false
|
||||||
tv.register(CircleGroupCell.self)
|
tv.register(CircleGroupCell.self)
|
||||||
tv.estimatedRowHeight = 70
|
tv.estimatedRowHeight = 70
|
||||||
|
tv.rowHeight = 70
|
||||||
tv.bounces = true
|
tv.bounces = true
|
||||||
tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0)
|
tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0)
|
||||||
return tv
|
return tv
|
||||||
|
|
@ -650,23 +652,26 @@ final class CircleGroupCell: UITableViewCell {
|
||||||
|
|
||||||
private let nameLabel: UILabel = {
|
private let nameLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
label.textColor = ThemeManager.shared.color.titleAuxColor
|
label.textColor = UIColor(hexStr: "#2A3648")
|
||||||
|
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let countLabel: UILabel = {
|
private let previewLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 10, weight: .medium)
|
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
label.textColor = UIColor(hexStr: "#999999")
|
label.textColor = UIColor(hexStr: "#AAAAAA")
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var unreadBadgeView: UIView = {
|
lazy var unreadBadgeView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = UIColor(hexStr: "#FF383C")
|
view.backgroundColor = UIColor(hexStr: "#FF383C")
|
||||||
view.cornerRadius = 7
|
view.cornerRadius = 8
|
||||||
view.isHidden = true
|
view.isHidden = true
|
||||||
|
view.clipsToBounds = true
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -674,65 +679,75 @@ final class CircleGroupCell: UITableViewCell {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 10, weight: .medium)
|
label.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
label.textColor = .white
|
label.textColor = .white
|
||||||
|
label.textAlignment = .center
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let timeLabel: UILabel = {
|
private let timeLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 10, weight: .regular)
|
label.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
label.textColor = UIColor(hexStr: "#999999")
|
label.textColor = UIColor(hexStr: "#AAAAAA")
|
||||||
label.textAlignment = .right
|
label.textAlignment = .right
|
||||||
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let dividerLine: UIView = {
|
private let tagStack: UIStackView = {
|
||||||
let v = UIView()
|
let view = UIStackView()
|
||||||
v.backgroundColor = UIColor(hexStr: "#F0F0F0")
|
view.axis = .horizontal
|
||||||
return v
|
view.alignment = .center
|
||||||
|
view.spacing = 4
|
||||||
|
view.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
override init(style: CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
backgroundColor = .white
|
backgroundColor = .white
|
||||||
|
contentView.clipsToBounds = false
|
||||||
|
clipsToBounds = false
|
||||||
|
|
||||||
contentView.addSubview(iconView)
|
contentView.addSubview(iconView)
|
||||||
contentView.addSubview(nameLabel)
|
|
||||||
contentView.addSubview(countLabel)
|
|
||||||
contentView.addSubview(unreadBadgeView)
|
contentView.addSubview(unreadBadgeView)
|
||||||
unreadBadgeView.addSubview(unreadBadge)
|
unreadBadgeView.addSubview(unreadBadge)
|
||||||
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(timeLabel)
|
contentView.addSubview(timeLabel)
|
||||||
contentView.addSubview(dividerLine)
|
contentView.addSubview(previewLabel)
|
||||||
|
contentView.addSubview(tagStack)
|
||||||
|
|
||||||
iconView.layoutChain
|
iconView.layoutChain
|
||||||
.left(15).edgesVertical(15)
|
.left(15)
|
||||||
.width(40).height(40)
|
.centerY()
|
||||||
|
.width(42)
|
||||||
nameLabel.layoutChain
|
.height(42)
|
||||||
.topToView(iconView, offset: 4)
|
|
||||||
.leftToRightOfView(iconView, offset: 12)
|
|
||||||
.right(90)
|
|
||||||
|
|
||||||
countLabel.layoutChain
|
|
||||||
.topToBottomOfView(nameLabel, offset: 4)
|
|
||||||
.leftToView(nameLabel)
|
|
||||||
|
|
||||||
unreadBadgeView.layoutChain
|
unreadBadgeView.layoutChain
|
||||||
.centerY(nameLabel)
|
.topToView(iconView, offset: -4)
|
||||||
.right(15)
|
.rightToView(iconView, offset: 6)
|
||||||
|
.height(16)
|
||||||
|
|
||||||
unreadBadge.layoutChain
|
unreadBadge.layoutChain
|
||||||
.edgesHorzontal(8)
|
.edgesHorzontal(5)
|
||||||
.edgesVertical(1)
|
.centerY()
|
||||||
|
|
||||||
timeLabel.layoutChain
|
timeLabel.layoutChain
|
||||||
|
.topToView(iconView, offset: 2)
|
||||||
.right(15)
|
.right(15)
|
||||||
.centerY(countLabel)
|
|
||||||
|
|
||||||
dividerLine.layoutChain
|
nameLabel.layoutChain
|
||||||
.edgesHorzontal(15)
|
.topToView(iconView, offset: 2)
|
||||||
.bottom()
|
.leftToRightOfView(iconView, offset: 12)
|
||||||
.height(0.5)
|
.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) {
|
required init?(coder: NSCoder) {
|
||||||
|
|
@ -742,10 +757,10 @@ final class CircleGroupCell: UITableViewCell {
|
||||||
func configure(_ model: GroupCellData) {
|
func configure(_ model: GroupCellData) {
|
||||||
let g = model.groupInfo
|
let g = model.groupInfo
|
||||||
iconView.image = model.localIcon
|
iconView.image = model.localIcon
|
||||||
nameLabel.text = g.groupName ?? ""
|
let count = g.memberCount
|
||||||
countLabel.text = "共\(g.memberCount)个成员"
|
nameLabel.text = "\(g.groupName ?? "") (\(count))"
|
||||||
|
previewLabel.text = model.lastMsgPreview
|
||||||
|
|
||||||
// 未读消息数
|
|
||||||
if model.unreadCount > 0 {
|
if model.unreadCount > 0 {
|
||||||
unreadBadgeView.isHidden = false
|
unreadBadgeView.isHidden = false
|
||||||
unreadBadge.text = model.unreadCount > 99 ? "99+" : "\(model.unreadCount)"
|
unreadBadge.text = model.unreadCount > 99 ? "99+" : "\(model.unreadCount)"
|
||||||
|
|
@ -753,13 +768,48 @@ final class CircleGroupCell: UITableViewCell {
|
||||||
unreadBadgeView.isHidden = true
|
unreadBadgeView.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后消息时间
|
|
||||||
if model.lastMsgTime > 0 {
|
if model.lastMsgTime > 0 {
|
||||||
let date = Date(timeIntervalSince1970: TimeInterval(model.lastMsgTime) / 1000)
|
let date = Date(timeIntervalSince1970: TimeInterval(model.lastMsgTime) / 1000)
|
||||||
timeLabel.text = date.formatForIM()
|
timeLabel.text = date.formatForIM()
|
||||||
} else {
|
} else {
|
||||||
timeLabel.text = ""
|
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 groupInfo: OIMGroupInfo
|
||||||
let unreadCount: Int
|
let unreadCount: Int
|
||||||
let lastMsgTime: Int // ms timestamp
|
let lastMsgTime: Int // ms timestamp
|
||||||
|
let lastMsgPreview: String
|
||||||
|
let tags: [String]
|
||||||
let localIcon: UIImage
|
let localIcon: UIImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +98,8 @@ final class GroupViewModel {
|
||||||
groupInfo: g,
|
groupInfo: g,
|
||||||
unreadCount: Int(conv?.unreadCount ?? 0),
|
unreadCount: Int(conv?.unreadCount ?? 0),
|
||||||
lastMsgTime: Int(conv?.latestMsgSendTime ?? 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 ?? "")
|
localIcon: getLocalIcon(groupId: g.groupID ?? "")
|
||||||
)
|
)
|
||||||
all.append(data)
|
all.append(data)
|
||||||
|
|
@ -108,6 +112,32 @@ final class GroupViewModel {
|
||||||
joinedSectionsSubject.onNext(all.mapSection())
|
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
|
// MARK: - Init
|
||||||
init() {
|
init() {
|
||||||
output = Output(
|
output = Output(
|
||||||
|
|
|
||||||
|
|
@ -1255,6 +1255,26 @@ extension HomeViewController {
|
||||||
if let view = mapView.view(for: selectedAnn) {
|
if let view = mapView.view(for: selectedAnn) {
|
||||||
raiseAnnotationView(view)
|
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
|
#endif
|
||||||
/// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id
|
/// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id
|
||||||
|
|
@ -1390,7 +1410,15 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
annotationView?.annotation = memberAnnotation
|
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?.configure(with: memberAnnotation.member)
|
||||||
|
annotationView?.setMood(
|
||||||
|
index: moodIndex(for: memberAnnotation.member),
|
||||||
|
selected: isSelected
|
||||||
|
)
|
||||||
annotationView?.onTap = { [weak self] ann in
|
annotationView?.onTap = { [weak self] ann in
|
||||||
self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
|
self?.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
|
||||||
}
|
}
|
||||||
|
|
@ -1399,9 +1427,7 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
if memberAnnotation.member.isCurrentUser {
|
if memberAnnotation.member.isCurrentUser {
|
||||||
annotationView?.updateHeading(currentHeading)
|
annotationView?.updateHeading(currentHeading)
|
||||||
}
|
}
|
||||||
let selectedIds = annotationIds(matching: selectedMemberId)
|
if isSelected {
|
||||||
if selectedIds.contains(memberAnnotation.member.id)
|
|
||||||
|| (memberAnnotation.member.isCurrentUser && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) {
|
|
||||||
annotationView?.zIndex = 10
|
annotationView?.zIndex = 10
|
||||||
} else if memberAnnotation.member.isCurrentUser {
|
} else if memberAnnotation.member.isCurrentUser {
|
||||||
annotationView?.zIndex = 1
|
annotationView?.zIndex = 1
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
#if !targetEnvironment(simulator)
|
#if !targetEnvironment(simulator)
|
||||||
import AMapNaviKit
|
import AMapNaviKit
|
||||||
|
import Lottie
|
||||||
|
|
||||||
final class MemberAnnotationView: MAAnnotationView {
|
final class MemberAnnotationView: MAAnnotationView {
|
||||||
|
|
||||||
|
|
@ -13,7 +14,7 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
var onTap: ((MemberAnnotation) -> Void)?
|
var onTap: ((MemberAnnotation) -> Void)?
|
||||||
|
|
||||||
// MARK: - Design Constants
|
// MARK: - Design Constants
|
||||||
private static let bubbleSize: CGFloat = 56
|
private static let bubbleSize: CGFloat = 66
|
||||||
private static let bubbleCorner: CGFloat = 14
|
private static let bubbleCorner: CGFloat = 14
|
||||||
private static let borderWidth: CGFloat = 4
|
private static let borderWidth: CGFloat = 4
|
||||||
private static let pointerWidth: CGFloat = 14
|
private static let pointerWidth: CGFloat = 14
|
||||||
|
|
@ -22,8 +23,10 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
private static let gapNameToBubble: CGFloat = 4
|
private static let gapNameToBubble: CGFloat = 4
|
||||||
private static let nameTagHeight: CGFloat = 20
|
private static let nameTagHeight: CGFloat = 20
|
||||||
private static let nameTagHPadding: CGFloat = 8
|
private static let nameTagHPadding: CGFloat = 8
|
||||||
private static let dotOuterSize: CGFloat = 12
|
private static let moodSize: CGFloat = 36
|
||||||
private static let dotInnerSize: CGFloat = 10
|
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 currentUserDotColor = UIColor(hexStr: "#5ED4FF")
|
||||||
private static let memberDotPalette: [UIColor] = [
|
private static let memberDotPalette: [UIColor] = [
|
||||||
|
|
@ -42,9 +45,22 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
]
|
]
|
||||||
|
|
||||||
private var showsNameTag = false
|
private var showsNameTag = false
|
||||||
|
private var showsMood = false
|
||||||
|
private var playingMoodName: String?
|
||||||
|
private var finishedMoodName: String?
|
||||||
|
|
||||||
private let containerView = UIView()
|
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 = {
|
private let nameTagView: UIView = {
|
||||||
let v = UIView()
|
let v = UIView()
|
||||||
v.backgroundColor = .white
|
v.backgroundColor = .white
|
||||||
|
|
@ -113,7 +129,7 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
private let dotCenterView: UIView = {
|
private let dotCenterView: UIView = {
|
||||||
let v = UIView()
|
let v = UIView()
|
||||||
v.backgroundColor = .white
|
v.backgroundColor = .white
|
||||||
v.layer.cornerRadius = 2
|
v.layer.cornerRadius = 3
|
||||||
return v
|
return v
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -136,6 +152,7 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
|
|
||||||
addSubview(containerView)
|
addSubview(containerView)
|
||||||
containerView.isUserInteractionEnabled = true
|
containerView.isUserInteractionEnabled = true
|
||||||
|
containerView.addSubview(moodView)
|
||||||
containerView.addSubview(nameTagView)
|
containerView.addSubview(nameTagView)
|
||||||
nameTagView.addSubview(nameLabel)
|
nameTagView.addSubview(nameLabel)
|
||||||
containerView.addSubview(bubbleView)
|
containerView.addSubview(bubbleView)
|
||||||
|
|
@ -174,12 +191,53 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
layoutMarker()
|
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() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
showsNameTag = false
|
showsNameTag = false
|
||||||
nameTagView.isHidden = true
|
nameTagView.isHidden = true
|
||||||
nameLabel.text = nil
|
nameLabel.text = nil
|
||||||
onTap = nil
|
onTap = nil
|
||||||
|
finishedMoodName = nil
|
||||||
|
stopMood()
|
||||||
layoutMarker()
|
layoutMarker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,8 +261,10 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
let gap = Self.gapBubbleToDot
|
let gap = Self.gapBubbleToDot
|
||||||
let dot = Self.dotOuterSize
|
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 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
|
var nameTagWidth: CGFloat = 0
|
||||||
if showsNameTag {
|
if showsNameTag {
|
||||||
|
|
@ -213,24 +273,33 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
nameTagWidth = min(max(textWidth + Self.nameTagHPadding * 2, 44), 120)
|
nameTagWidth = min(max(textWidth + Self.nameTagHPadding * 2, 44), 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalWidth = max(bubble, nameTagWidth)
|
let totalWidth = max(bubble, nameTagWidth, showsMood ? Self.moodSize : 0)
|
||||||
let totalHeight = nameH + nameGap + bubble + pointerH + gap + dot
|
let totalHeight = moodH + moodGap + nameH + nameGap + bubble + pointerH + gap + dot
|
||||||
|
|
||||||
bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight)
|
bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight)
|
||||||
containerView.frame = bounds
|
containerView.frame = bounds
|
||||||
centerOffset = CGPoint(x: 0, y: -(totalHeight / 2 - Self.dotOuterSize / 2))
|
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 {
|
if showsNameTag {
|
||||||
nameTagView.frame = CGRect(
|
nameTagView.frame = CGRect(
|
||||||
x: (totalWidth - nameTagWidth) / 2,
|
x: (totalWidth - nameTagWidth) / 2,
|
||||||
y: 0,
|
y: moodH + moodGap,
|
||||||
width: nameTagWidth,
|
width: nameTagWidth,
|
||||||
height: Self.nameTagHeight
|
height: Self.nameTagHeight
|
||||||
)
|
)
|
||||||
nameLabel.frame = nameTagView.bounds.insetBy(dx: Self.nameTagHPadding - 2, dy: 0)
|
nameLabel.frame = nameTagView.bounds.insetBy(dx: Self.nameTagHPadding - 2, dy: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let bubbleY = nameH + nameGap
|
let bubbleY = moodH + moodGap + nameH + nameGap
|
||||||
bubbleView.frame = CGRect(
|
bubbleView.frame = CGRect(
|
||||||
x: (totalWidth - bubble) / 2,
|
x: (totalWidth - bubble) / 2,
|
||||||
y: bubbleY,
|
y: bubbleY,
|
||||||
|
|
@ -262,7 +331,7 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
x: inset, y: inset,
|
x: inset, y: inset,
|
||||||
width: Self.dotInnerSize, height: Self.dotInnerSize
|
width: Self.dotInnerSize, height: Self.dotInnerSize
|
||||||
)
|
)
|
||||||
let centerSize: CGFloat = 4
|
let centerSize: CGFloat = 6
|
||||||
let centerInset = (Self.dotInnerSize - centerSize) / 2
|
let centerInset = (Self.dotInnerSize - centerSize) / 2
|
||||||
dotCenterView.frame = CGRect(
|
dotCenterView.frame = CGRect(
|
||||||
x: centerInset, y: centerInset,
|
x: centerInset, y: centerInset,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import RxSwift
|
import RxSwift
|
||||||
import RxCocoa
|
import RxCocoa
|
||||||
import HXPHPicker
|
|
||||||
|
|
||||||
final class FeedbackVC: BaseViewController {
|
final class FeedbackVC: BaseViewController {
|
||||||
|
|
||||||
|
|
@ -98,19 +98,14 @@ final class FeedbackVC: BaseViewController {
|
||||||
private func showAlbum() {
|
private func showAlbum() {
|
||||||
let remainingCount = 3 - selectedImages.count
|
let remainingCount = 3 - selectedImages.count
|
||||||
guard remainingCount > 0, !isSubmitting else { return }
|
guard remainingCount > 0, !isSubmitting else { return }
|
||||||
|
let picker = PhotoPickerVC(maxCount: remainingCount)
|
||||||
let config = PhotoTools.getWXPickerConfig()
|
picker.onConfirm = { [weak self] images in
|
||||||
config.selectOptions = [.photo]
|
guard let self else { return }
|
||||||
config.selectMode = .multiple
|
self.selectedImages.append(contentsOf: images.prefix(remainingCount))
|
||||||
config.maximumSelectedCount = remainingCount
|
self.selectedImages = Array(self.selectedImages.prefix(3))
|
||||||
config.allowSyncICloudWhenSelectPhoto = false
|
self.rootView.updateImages(self.selectedImages)
|
||||||
config.previewView.bottomView.editButtonHidden = true
|
}
|
||||||
config.photoList.allowAddCamera = false
|
present(picker, animated: true)
|
||||||
|
|
||||||
let pickerController = PhotoPickerController(picker: config)
|
|
||||||
pickerController.pickerDelegate = self
|
|
||||||
pickerController.modalPresentationStyle = .fullScreen
|
|
||||||
present(pickerController, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submit() {
|
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 recordedDuration: TimeInterval = 0
|
||||||
private var isPressingRecord = false
|
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() {
|
override func loadView() {
|
||||||
rootView = PigeonMessageView(frame: UIScreen.main.bounds)
|
rootView = PigeonMessageView(frame: UIScreen.main.bounds)
|
||||||
view = rootView
|
view = rootView
|
||||||
|
|
@ -144,7 +133,13 @@ final class PigeonMessageVC: BaseViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func selectImage() {
|
@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() {
|
@objc private func deleteImage() {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
// ScanVC.swift
|
// ScanVC.swift
|
||||||
// QuickLocation
|
// QuickLocation
|
||||||
//
|
//
|
||||||
// Created by 八条 on 2026/6/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
@ -12,48 +10,41 @@ import RxSwift
|
||||||
class ScanVC: BaseViewController {
|
class ScanVC: BaseViewController {
|
||||||
|
|
||||||
override var isNavigationBarHidden: Bool { true }
|
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() {
|
override func loadView() {
|
||||||
rootView = ScanView(frame: UIScreen.main.bounds)
|
rootView = CameraCaptureView(mode: .scan)
|
||||||
view = rootView
|
view = rootView
|
||||||
}
|
}
|
||||||
|
|
||||||
private let scanDoneAction: ((String) -> Void)?
|
|
||||||
|
|
||||||
private var captureSession: AVCaptureSession!
|
|
||||||
private var previewLayer: AVCaptureVideoPreviewLayer!
|
|
||||||
private var isScanning = false
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupCamera()
|
rootView.onClose = { [weak self] in
|
||||||
}
|
self?.closeCapture()
|
||||||
|
}
|
||||||
override func viewDidLayoutSubviews() {
|
rootView.onScan = { [weak self] text in
|
||||||
super.viewDidLayoutSubviews()
|
self?.handleScanResult(text: text)
|
||||||
previewLayer?.frame = view.bounds
|
}
|
||||||
|
rootView.onOpenAlbum = { [weak self] in
|
||||||
|
self?.openAlbum()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
if let session = captureSession, !session.isRunning {
|
isHandling = false
|
||||||
DispatchQueue.global(qos: .background).async {
|
rootView.startSession()
|
||||||
session.startRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
startScanAnimation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
if let session = captureSession, session.isRunning {
|
rootView.stopSession()
|
||||||
session.stopRunning()
|
|
||||||
}
|
|
||||||
rootView.scanLineView.layer.removeAllAnimations()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(scanDoneAction: ((String) -> Void)? = nil) {
|
init(scanDoneAction: ((String) -> Void)? = nil) {
|
||||||
self.scanDoneAction = scanDoneAction
|
self.scanDoneAction = scanDoneAction
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
@ -63,123 +54,75 @@ class ScanVC: BaseViewController {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleScanResult(text: String) {
|
private func closeCapture() {
|
||||||
guard !isScanning else { return }
|
if presentingViewController != nil {
|
||||||
isScanning = true
|
dismiss(animated: true)
|
||||||
captureSession.stopRunning()
|
} else {
|
||||||
rootView.scanLineView.alpha = 0
|
navigationController?.popViewController(animated: true)
|
||||||
rootView.scanLineView.layer.removeAllAnimations()
|
|
||||||
if let scanDoneAction = scanDoneAction {
|
|
||||||
scanDoneAction(text)
|
|
||||||
AppRouter.shared.popOrDismiss()
|
|
||||||
}
|
}
|
||||||
// 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) {
|
private func requestJoinGroup(code: String) {
|
||||||
dl.showLoading()
|
DLToast.showLoading()
|
||||||
GroupService.operate(opType: "join", requestData: ["share_code" : code]).subscribe(onNext: { response in
|
GroupService.operate(opType: "join", requestData: ["share_code": code]).subscribe(onNext: { response in
|
||||||
self.dl.show(text: "申请成功") {
|
DLToast.showSuccess(text: response.message ?? "申请成功") {
|
||||||
AppRouter.shared.popOrDismiss()
|
AppRouter.shared.popOrDismiss()
|
||||||
}
|
}
|
||||||
}, onError: { [weak self] (error) in
|
}, onError: { [weak self] _ in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
DLToast.showError(text: "无法识别该二维码")
|
||||||
self?.isScanning = false
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
self?.startSession()
|
self?.isHandling = false
|
||||||
|
self?.rootView.startSession()
|
||||||
}
|
}
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startSession() {
|
private func openAlbum() {
|
||||||
if let session = captureSession, !session.isRunning {
|
guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { return }
|
||||||
DispatchQueue.global(qos: .background).async {
|
let picker = UIImagePickerController()
|
||||||
session.startRunning()
|
picker.sourceType = .photoLibrary
|
||||||
}
|
picker.delegate = self
|
||||||
}
|
present(picker, animated: true)
|
||||||
startScanAnimation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 扫描线动画
|
private func decodeQR(from image: UIImage) {
|
||||||
private func startScanAnimation() {
|
guard let ciImage = CIImage(image: image) else {
|
||||||
rootView.scanLineView.layer.removeAllAnimations()
|
DLToast.show(text: "无法识别二维码")
|
||||||
rootView.scanLineView.alpha = 1
|
isHandling = false
|
||||||
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()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||||
UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat, .curveLinear]) {
|
let features = detector?.features(in: ciImage) ?? []
|
||||||
self.rootView.scanLineView.frame.origin.y = boxH - 2
|
if let qr = features.first as? CIQRCodeFeature, let text = qr.messageString, !text.isEmpty {
|
||||||
}
|
handleScanResult(text: text)
|
||||||
}
|
} else {
|
||||||
|
DLToast.show(text: "未识别到二维码")
|
||||||
// MARK: - 相机配置
|
isHandling = false
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AVCaptureMetadataOutputObjectsDelegate
|
extension ScanVC: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
extension ScanVC: AVCaptureMetadataOutputObjectsDelegate {
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
picker.dismiss(animated: true)
|
||||||
didOutput metadataObjects: [AVMetadataObject],
|
}
|
||||||
from connection: AVCaptureConnection) {
|
|
||||||
guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
let result = obj.stringValue else { return }
|
picker.dismiss(animated: true)
|
||||||
handleScanResult(text: result)
|
if let image = info[.originalImage] as? UIImage {
|
||||||
|
decodeQR(from: image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||