feat: add travel album OTG import flow
This commit is contained in:
@ -0,0 +1,537 @@
|
||||
//
|
||||
// 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) -> 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()
|
||||
}
|
||||
|
||||
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)
|
||||
self?.collectionView.reloadData()
|
||||
}
|
||||
viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in
|
||||
self?.updateHelpVisibility(show)
|
||||
}
|
||||
}
|
||||
|
||||
private func render(_ state: TravelAlbumCameraImportState) {
|
||||
switch state {
|
||||
case .idle, .loading:
|
||||
collectionView.isHidden = true
|
||||
messageLabel.isHidden = true
|
||||
helpButton.isHidden = true
|
||||
importBarButton.isEnabled = false
|
||||
navigationItem.leftBarButtonItem?.isEnabled = true
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
case .loaded(let loadedSections):
|
||||
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):
|
||||
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 count):
|
||||
activityIndicator.stopAnimating()
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.onImportFinished?(count)
|
||||
}
|
||||
|
||||
case .failed(let message):
|
||||
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)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
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
|
||||
let section = sections[indexPath.section]
|
||||
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
|
||||
guard let self else { return }
|
||||
self.viewModel.toggleSection(section)
|
||||
self.collectionView.reloadSections(IndexSet(integer: 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)
|
||||
collectionView.reloadItems(at: [indexPath])
|
||||
collectionView.reloadSections(IndexSet(integer: 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机历史照片导入 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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user