- 扫一扫、拍照

- 相册选择器
This commit is contained in:
linshujie 2026-08-21 16:47:33 +08:00
parent d362ca565a
commit 4daddba6ba
41 changed files with 2428 additions and 419 deletions

View File

@ -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 */,

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

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

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}
}

View File

@ -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 = ""
}
}

View File

@ -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: -

View File

@ -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()
senderNameView.translatesAutoresizingMaskIntoConstraints = false
if isOutgoing {
photoView.addSubview(loadingView)
loadingView.layoutChain.centerX().centerY()
avatarView.layoutChain avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 10) .topToBottomOfView(timeLabel, offset: 10)
.right(12).width(38).height(38) .right(12).width(38).height(38)
senderNameView.translatesAutoresizingMaskIntoConstraints = false
senderNameView.layoutChain senderNameView.layoutChain
.topToView(avatarView) .topToView(avatarView)
.rightToLeftOfView(avatarView, offset: -9) .rightToLeftOfView(avatarView, offset: -9)
photoView.layoutChain photoView.layoutChain
.topToBottomOfView(senderNameView, offset: 5) .topToBottomOfView(senderNameView, offset: 5)
.rightToView(senderNameView, offset: 0) .rightToView(senderNameView, offset: 0)
.width(ChatImageLayout.fallback.width)
.height(ChatImageLayout.fallback.height)
.bottom(10) .bottom(10)
} } else {
loadingView.isHidden = true
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: -
final class ImageReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner)
if !msg.imageUrl.isEmpty {
photoView.dl.setImage(with: msg.imageUrl)
}
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
}
@objc private func onTap() { onImageTap?() }
private let timeLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hexStr: "#999999")
label.textAlignment = .center
return label
}()
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 10
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
// iv.borderWidth = 2
// iv.borderColor = .white
return iv
}()
private let senderNameView = ChatSenderNameView()
lazy var photoView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 8
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#F0F0F0")
iv.isUserInteractionEnabled = true
return iv
}()
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(senderNameView)
contentView.addSubview(photoView)
contentView.addSubview(avatarView)
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
photoView.addGestureRecognizer(tap)
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 10) .topToBottomOfView(timeLabel, offset: 10)
.left(12).width(38).height(38) .left(12).width(38).height(38)
senderNameView.translatesAutoresizingMaskIntoConstraints = false
senderNameView.layoutChain senderNameView.layoutChain
.topToView(avatarView) .topToView(avatarView)
.leftToRightOfView(avatarView, offset: 9) .leftToRightOfView(avatarView, offset: 9)
photoView.layoutChain photoView.layoutChain
.topToBottomOfView(senderNameView, offset: 5) .topToBottomOfView(senderNameView, offset: 5)
.leftToView(senderNameView) .leftToView(senderNameView)
.width(160).height(160) .width(ChatImageLayout.fallback.width)
.height(ChatImageLayout.fallback.height)
.bottom(10) .bottom(10)
} }
}
required init?(coder: NSCoder) { override func prepareForReuse() {
fatalError("init(coder:) has not been implemented") super.prepareForReuse()
photoView.image = nil
onImageTap = nil
onNeedRelayout = nil
configuredId = nil
loadingView.stopAnimating()
loadingView.isHidden = true
} }
} }

View File

@ -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 {
) )
} }
/// / snapshotURL `??`
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 ?? "")

View File

@ -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)
} }
} }

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -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) {}
}

View File

@ -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() {

View File

@ -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,46 +10,39 @@ 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()
}
rootView.onScan = { [weak self] text in
self?.handleScanResult(text: text)
}
rootView.onOpenAlbum = { [weak self] in
self?.openAlbum()
} }
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
} }
override func viewWillAppear(_ animated: Bool) { 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) {
@ -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)
}
} }
} }