Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift
2026-07-10 11:02:00 +08:00

585 lines
22 KiB
Swift

//
// TravelAlbumCameraImportViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class TravelAlbumCameraImportViewController: UIViewController {
private enum Layout {
static let columns: CGFloat = 3
static let spacing: CGFloat = 2
static let inset: CGFloat = 2
static let headerHeight: CGFloat = 44
}
private let viewModel: TravelAlbumCameraImportViewModel
var onImportFinished: ((Int, Int, [String]) -> Void)?
var onImportCancelled: (() -> Void)?
private var collectionView: UICollectionView!
private var sections: [TravelAlbumCameraImportSection] = []
private var lastCollectionWidth: CGFloat = 0
private lazy var importBarButton = UIBarButtonItem(
title: "导入 (0)",
style: .done,
target: self,
action: #selector(importTapped)
)
private lazy var helpBarButton = UIBarButtonItem(
image: UIImage(systemName: "questionmark.circle"),
style: .plain,
target: self,
action: #selector(helpTapped)
)
private let activityIndicator = UIActivityIndicatorView(style: .large)
private let messageLabel = UILabel()
private let helpButton = UIButton(type: .system)
///
init(viewModel: TravelAlbumCameraImportViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "选择照片"
view.backgroundColor = .white
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "取消",
style: .plain,
target: self,
action: #selector(cancelTapped)
)
navigationItem.rightBarButtonItem = importBarButton
importBarButton.isEnabled = false
setupUI()
setupConstraints()
bindViewModel()
viewModel.loadPhotos()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let width = collectionView.bounds.width
guard width > 0, width != lastCollectionWidth else { return }
lastCollectionWidth = width
collectionView.collectionViewLayout.invalidateLayout()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.presentationController?.delegate = self
}
private func setupUI() {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = Layout.spacing
layout.minimumInteritemSpacing = Layout.spacing
layout.sectionInset = UIEdgeInsets(
top: Layout.inset,
left: Layout.inset,
bottom: Layout.inset,
right: Layout.inset
)
layout.headerReferenceSize = CGSize(width: 0, height: Layout.headerHeight)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = .white
collectionView.allowsMultipleSelection = true
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(
TravelAlbumCameraImportPhotoCell.self,
forCellWithReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier
)
collectionView.register(
TravelAlbumCameraImportSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier
)
activityIndicator.hidesWhenStopped = true
messageLabel.numberOfLines = 0
messageLabel.textAlignment = .center
messageLabel.textColor = AppColor.textSecondary
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.isHidden = true
helpButton.setTitle("如何切换到 MTP 模式", for: .normal)
helpButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
helpButton.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
helpButton.setTitleColor(AppColor.primary, for: .normal)
helpButton.layer.cornerRadius = 9
helpButton.isHidden = true
helpButton.addTarget(self, action: #selector(helpTapped), for: .touchUpInside)
view.addSubview(collectionView)
view.addSubview(activityIndicator)
view.addSubview(messageLabel)
view.addSubview(helpButton)
}
private func setupConstraints() {
collectionView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
activityIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
messageLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-28)
make.leading.greaterThanOrEqualToSuperview().offset(32)
make.trailing.lessThanOrEqualToSuperview().offset(-32)
}
helpButton.snp.makeConstraints { make in
make.top.equalTo(messageLabel.snp.bottom).offset(20)
make.centerX.equalToSuperview()
make.height.equalTo(38)
make.width.greaterThanOrEqualTo(160)
}
}
private func bindViewModel() {
viewModel.onStateUpdated = { [weak self] state in
self?.render(state)
}
viewModel.onSelectionUpdated = { [weak self] count in
self?.updateImportButton(count: count)
}
viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in
self?.updateHelpVisibility(show)
}
}
private func render(_ state: TravelAlbumCameraImportState) {
switch state {
case .idle, .loading:
isModalInPresentation = false
collectionView.isHidden = true
messageLabel.isHidden = true
helpButton.isHidden = true
importBarButton.isEnabled = false
navigationItem.leftBarButtonItem?.isEnabled = true
activityIndicator.startAnimating()
case .loaded(let loadedSections):
isModalInPresentation = false
activityIndicator.stopAnimating()
collectionView.isHidden = false
messageLabel.isHidden = true
helpButton.isHidden = true
navigationItem.leftBarButtonItem?.isEnabled = true
sections = loadedSections
collectionView.reloadData()
case .importing(let current, let total):
isModalInPresentation = true
collectionView.isHidden = true
messageLabel.isHidden = false
messageLabel.text = "正在导入 \(current)/\(total)..."
helpButton.isHidden = true
importBarButton.isEnabled = false
navigationItem.leftBarButtonItem?.isEnabled = false
activityIndicator.startAnimating()
case .finished(let importedCount, let failedCount, let importedPhotoIds):
isModalInPresentation = false
activityIndicator.stopAnimating()
dismiss(animated: true) { [weak self] in
self?.onImportFinished?(importedCount, failedCount, importedPhotoIds)
}
case .failed(let message):
isModalInPresentation = false
activityIndicator.stopAnimating()
collectionView.isHidden = true
messageLabel.isHidden = false
messageLabel.text = message
importBarButton.isEnabled = false
navigationItem.leftBarButtonItem?.isEnabled = true
helpButton.isHidden = !viewModel.shouldShowSonyMTPHelp
}
}
private func updateHelpVisibility(_ show: Bool) {
navigationItem.rightBarButtonItems = show ? [importBarButton, helpBarButton] : [importBarButton]
if case .failed = viewModel.state {
helpButton.isHidden = !show
}
}
private func updateImportButton(count: Int) {
importBarButton.title = "导入 (\(count))"
importBarButton.isEnabled = count > 0
}
private func thumbnailPixelSize() -> Int {
guard let flow = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else {
return 300
}
return Int(itemSize(layout: flow).width * UIScreen.main.scale)
}
private func itemSize(layout: UICollectionViewFlowLayout) -> CGSize {
let totalSpacing = layout.sectionInset.left
+ layout.sectionInset.right
+ layout.minimumInteritemSpacing * (Layout.columns - 1)
let width = floor((collectionView.bounds.width - totalSpacing) / Layout.columns)
return CGSize(width: width, height: width + 28)
}
private func applyHeader(_ header: TravelAlbumCameraImportSectionHeaderView, at sectionIndex: Int) {
let section = sections[sectionIndex]
header.apply(
title: section.title,
count: section.photos.count,
importableCount: viewModel.importablePhotoCount(in: section),
importedCount: viewModel.importedPhotoCount(in: section),
hasImportablePhotos: viewModel.hasImportablePhotos(in: section),
fullySelected: viewModel.isSectionFullySelected(section),
partiallySelected: viewModel.isSectionPartiallySelected(section)
)
header.onToggle = { [weak self] in
self?.toggleSection(at: sectionIndex)
}
}
private func refreshVisibleHeader(at sectionIndex: Int) {
let indexPath = IndexPath(item: 0, section: sectionIndex)
guard let header = collectionView.supplementaryView(
forElementKind: UICollectionView.elementKindSectionHeader,
at: indexPath
) as? TravelAlbumCameraImportSectionHeaderView else {
return
}
applyHeader(header, at: sectionIndex)
}
private func reloadItemsWithoutAnimation(_ indexPaths: [IndexPath]) {
UIView.performWithoutAnimation {
collectionView.reloadItems(at: indexPaths)
collectionView.layoutIfNeeded()
}
}
private func toggleSection(at sectionIndex: Int) {
guard sections.indices.contains(sectionIndex) else { return }
viewModel.toggleSection(sections[sectionIndex])
let indexPaths = sections[sectionIndex].photos.indices.map {
IndexPath(item: $0, section: sectionIndex)
}
reloadItemsWithoutAnimation(indexPaths)
refreshVisibleHeader(at: sectionIndex)
}
@objc private func cancelTapped() {
onImportCancelled?()
dismiss(animated: true)
}
@objc private func importTapped() {
viewModel.importSelected()
}
@objc private func helpTapped() {
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
UIApplication.shared.open(url)
}
}
extension TravelAlbumCameraImportViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
sections.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
sections[section].photos.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumCameraImportPhotoCell
let photo = sections[indexPath.section].photos[indexPath.item]
cell.apply(
photo: photo,
selected: viewModel.isPhotoSelected(id: photo.id),
alreadyImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id),
maxPixelSize: thumbnailPixelSize()
) { [weak self] photo, maxPixelSize in
await self?.viewModel.thumbnailData(for: photo, maxPixelSize: maxPixelSize)
}
return cell
}
func collectionView(
_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath
) -> UICollectionReusableView {
guard kind == UICollectionView.elementKindSectionHeader else {
return UICollectionReusableView()
}
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier,
for: indexPath
) as! TravelAlbumCameraImportSectionHeaderView
applyHeader(header, at: indexPath.section)
return header
}
}
extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let photo = sections[indexPath.section].photos[indexPath.item]
guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return }
viewModel.togglePhoto(id: photo.id)
reloadItemsWithoutAnimation([indexPath])
refreshVisibleHeader(at: indexPath.section)
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
guard let flow = collectionViewLayout as? UICollectionViewFlowLayout else {
return CGSize(width: 120, height: 148)
}
return itemSize(layout: flow)
}
}
extension TravelAlbumCameraImportViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
onImportCancelled?()
}
}
/// Cell
private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell"
private let imageView = UIImageView()
private let filenameLabel = UILabel()
private let checkmarkView = UIImageView()
private let selectedOverlayView = UIView()
private let importedOverlayView = UIView()
private let importedBadgeLabel = UILabel()
private var representedPhotoId: String?
private var loadToken = UUID()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
representedPhotoId = nil
loadToken = UUID()
imageView.image = nil
filenameLabel.text = nil
applySelection(false, alreadyImported: false)
applyImported(false)
}
///
func apply(
photo: CameraObject,
selected: Bool,
alreadyImported: Bool,
maxPixelSize: Int,
thumbnailProvider: @escaping (CameraObject, Int) async -> Data?
) {
representedPhotoId = photo.id
filenameLabel.text = photo.filename
setPlaceholderImage()
applyImported(alreadyImported)
applySelection(selected, alreadyImported: alreadyImported)
let token = UUID()
loadToken = token
Task { @MainActor in
guard let data = await thumbnailProvider(photo, maxPixelSize),
self.loadToken == token,
self.representedPhotoId == photo.id,
let image = UIImage(data: data) else {
return
}
self.imageView.image = image
self.imageView.tintColor = nil
}
}
private func setupUI() {
contentView.backgroundColor = AppColor.pageBackground
contentView.clipsToBounds = true
contentView.layer.cornerRadius = 4
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
selectedOverlayView.backgroundColor = AppColor.primary.withAlphaComponent(0.25)
selectedOverlayView.isHidden = true
importedOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
importedOverlayView.isHidden = true
importedBadgeLabel.text = "已在相册"
importedBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold)
importedBadgeLabel.textColor = .white
importedBadgeLabel.textAlignment = .center
importedBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55)
importedBadgeLabel.layer.cornerRadius = 4
importedBadgeLabel.clipsToBounds = true
importedBadgeLabel.isHidden = true
checkmarkView.image = UIImage(systemName: "checkmark.circle.fill")
checkmarkView.tintColor = AppColor.primary
checkmarkView.isHidden = true
filenameLabel.font = .systemFont(ofSize: 9, weight: .medium)
filenameLabel.textColor = AppColor.textSecondary
filenameLabel.textAlignment = .center
filenameLabel.numberOfLines = 2
[imageView, selectedOverlayView, importedOverlayView, importedBadgeLabel, checkmarkView, filenameLabel].forEach {
contentView.addSubview($0)
}
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(contentView.snp.width)
}
selectedOverlayView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(imageView)
}
importedOverlayView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(imageView)
}
importedBadgeLabel.snp.makeConstraints { make in
make.center.equalTo(imageView)
make.height.equalTo(18)
make.width.greaterThanOrEqualTo(52)
}
checkmarkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(22)
}
filenameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview().inset(2)
make.bottom.lessThanOrEqualToSuperview().offset(-2)
}
}
private func setPlaceholderImage() {
let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .light)
imageView.image = UIImage(systemName: "photo", withConfiguration: config)
imageView.tintColor = AppColor.textTertiary
}
private func applySelection(_ selected: Bool, alreadyImported: Bool) {
let showSelection = selected && !alreadyImported
selectedOverlayView.isHidden = !showSelection
checkmarkView.isHidden = !showSelection
contentView.layer.borderWidth = showSelection ? 2 : 0
contentView.layer.borderColor = showSelection ? AppColor.primary.cgColor : nil
}
private func applyImported(_ alreadyImported: Bool) {
importedOverlayView.isHidden = !alreadyImported
importedBadgeLabel.isHidden = !alreadyImported
contentView.alpha = alreadyImported ? 0.55 : 1
}
}
/// Header
private final class TravelAlbumCameraImportSectionHeaderView: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumCameraImportSectionHeaderView"
var onToggle: (() -> Void)?
private let checkButton = UIButton(type: .system)
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(
title: String,
count: Int,
importableCount: Int,
importedCount: Int,
hasImportablePhotos: Bool,
fullySelected: Bool,
partiallySelected: Bool
) {
titleLabel.text = importedCount > 0 ? "\(title) · 可导入 \(importableCount) / 共 \(count)" : "\(title) · \(count)"
checkButton.isEnabled = hasImportablePhotos
let symbolName: String
if !hasImportablePhotos {
symbolName = "circle"
} else if fullySelected {
symbolName = "checkmark.circle.fill"
} else if partiallySelected {
symbolName = "minus.circle.fill"
} else {
symbolName = "circle"
}
checkButton.setImage(UIImage(systemName: symbolName), for: .normal)
checkButton.tintColor = hasImportablePhotos && (fullySelected || partiallySelected) ? AppColor.primary : AppColor.textTertiary
}
private func setupUI() {
backgroundColor = .white
checkButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
addSubview(checkButton)
addSubview(titleLabel)
checkButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(28)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(checkButton.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
}
}
@objc private func toggleTapped() {
onToggle?()
}
}