390 lines
13 KiB
Swift
390 lines
13 KiB
Swift
//
|
|
// 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 = ""
|
|
}
|
|
}
|
|
|