feat: add AI retouch and album preview actions
This commit is contained in:
@@ -17,15 +17,16 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let configuration: TravelAlbumPreviewConfiguration
|
||||
private let actionHandler: any TravelAlbumPreviewActionHandling
|
||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||
private let onProjectDeleted: ((Int) -> Void)?
|
||||
private var nodes: [TravelAlbumPreviewNode] = []
|
||||
private var currentNodeIndex = 0
|
||||
private var dragStartIndex = 0
|
||||
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
||||
private var chromeVisible = true
|
||||
private var isLoadingMore = false
|
||||
private var isDeletingProject = false
|
||||
private var didApplyInitialPosition = false
|
||||
private var lastCollectionSize: CGSize = .zero
|
||||
private var previewPrefetcher: ImagePrefetcher?
|
||||
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private let topChrome = UIView()
|
||||
@@ -37,6 +38,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let divider = UIView()
|
||||
private let tabStack = UIStackView()
|
||||
private let actionStack = UIStackView()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private var tabHeightConstraint: Constraint?
|
||||
|
||||
/// 创建全屏预览页。
|
||||
@@ -46,13 +48,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
startProjectIndex: Int,
|
||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||
loadMore: TravelAlbumPreviewLoadMore? = nil
|
||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||
onProjectDeleted: ((Int) -> Void)? = nil
|
||||
) {
|
||||
self.projects = Self.deduplicated(projects)
|
||||
self.totalCount = max(totalCount, projects.count)
|
||||
self.configuration = configuration
|
||||
self.actionHandler = actionHandler
|
||||
self.loadMore = loadMore
|
||||
self.onProjectDeleted = onProjectDeleted
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
|
||||
@@ -70,7 +74,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
updateForCurrentNode()
|
||||
prefetchAdjacentImages()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
@@ -81,13 +84,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
if didApplyInitialPosition {
|
||||
setPage(currentNodeIndex, animated: false)
|
||||
prefetchAdjacentImages()
|
||||
}
|
||||
}
|
||||
guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return }
|
||||
didApplyInitialPosition = true
|
||||
setPage(currentNodeIndex, animated: false)
|
||||
upgradeCurrentCellToDetailImage()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
@@ -193,7 +194,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
|
||||
private func configureActions() {
|
||||
let aiButton = makeActionButton(title: "AI修复", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA))
|
||||
let deleteButton = makeActionButton(title: "删除", systemName: "trash", color: UIColor(hex: 0xF87171))
|
||||
let deleteConfiguration = makeActionButton(
|
||||
title: "删除",
|
||||
systemName: "trash",
|
||||
color: UIColor(hex: 0xF87171)
|
||||
).configuration
|
||||
deleteButton.configuration = deleteConfiguration
|
||||
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
deleteButton.accessibilityLabel = "删除"
|
||||
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
|
||||
let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white)
|
||||
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
@@ -305,8 +314,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
let oldProjectIndex = currentNode?.projectIndex
|
||||
guard oldNodeIndex != index else {
|
||||
updateForCurrentNode()
|
||||
upgradeCurrentCellToDetailImage()
|
||||
prefetchAdjacentImages()
|
||||
return
|
||||
}
|
||||
currentNodeIndex = index
|
||||
@@ -316,8 +323,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
}
|
||||
collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() }
|
||||
updateForCurrentNode()
|
||||
upgradeCurrentCellToDetailImage()
|
||||
prefetchAdjacentImages()
|
||||
}
|
||||
|
||||
private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) {
|
||||
@@ -326,38 +331,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell
|
||||
else { return }
|
||||
let project = projects[nodes[nodeIndex].projectIndex]
|
||||
cell.apply(asset: project.asset(for: .original), quality: .preview)
|
||||
}
|
||||
|
||||
private func upgradeCurrentCellToDetailImage() {
|
||||
guard let cell = collectionView.cellForItem(
|
||||
at: IndexPath(item: currentNodeIndex, section: 0)
|
||||
) as? TravelAlbumPreviewImageCell else { return }
|
||||
cell.apply(asset: currentAsset, quality: .detail)
|
||||
}
|
||||
|
||||
private func prefetchAdjacentImages() {
|
||||
previewPrefetcher?.stop()
|
||||
let indexes = [currentNodeIndex - 2, currentNodeIndex - 1, currentNodeIndex + 1, currentNodeIndex + 2]
|
||||
let urls = indexes.compactMap { index -> URL? in
|
||||
guard nodes.indices.contains(index) else { return nil }
|
||||
let node = nodes[index]
|
||||
let project = projects[node.projectIndex]
|
||||
let asset = project.asset(for: node.kind) ?? project.asset(for: .original)
|
||||
guard let text = asset?.previewURL, !text.isEmpty else { return nil }
|
||||
return URL(string: text)
|
||||
}
|
||||
guard !urls.isEmpty else {
|
||||
previewPrefetcher = nil
|
||||
return
|
||||
}
|
||||
let prefetcher = ImagePrefetcher(
|
||||
urls: urls,
|
||||
options: TravelAlbumPreviewImageRequest.previewOptions(viewSize: collectionView.bounds.size)
|
||||
)
|
||||
prefetcher.maxConcurrentDownloads = 2
|
||||
previewPrefetcher = prefetcher
|
||||
prefetcher.start()
|
||||
cell.apply(asset: project.asset(for: .original))
|
||||
}
|
||||
|
||||
private func setPage(_ index: Int, animated: Bool) {
|
||||
@@ -400,8 +374,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
self.setPage(self.currentNodeIndex, animated: false)
|
||||
self.isLoadingMore = false
|
||||
self.updateForCurrentNode()
|
||||
self.upgradeCurrentCellToDetailImage()
|
||||
self.prefetchAdjacentImages()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +449,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
guard let id = currentProject?.originalMaterialId else { return }
|
||||
guard currentProject != nil, !isDeletingProject else { return }
|
||||
let alert = UIAlertController(
|
||||
title: "删除整个项目",
|
||||
message: "将同时删除原图及其全部关联图片,是否继续?",
|
||||
@@ -485,14 +457,87 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.performAction { [actionHandler = self.actionHandler] in
|
||||
await actionHandler.deleteProject(originalMaterialId: id)
|
||||
}
|
||||
self?.deleteCurrentProjectAfterConfirmation()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。
|
||||
func deleteCurrentProjectAfterConfirmation() {
|
||||
guard !isDeletingProject,
|
||||
let project = currentProject,
|
||||
let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id })
|
||||
else { return }
|
||||
isDeletingProject = true
|
||||
updateDeleteButton()
|
||||
|
||||
Task { [weak self, actionHandler] in
|
||||
let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId)
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self.applySuccessfulDeletion(
|
||||
project: project,
|
||||
deletedProjectIndex: deletedProjectIndex,
|
||||
message: message
|
||||
)
|
||||
case .failure(let message), .unavailable(let message):
|
||||
self.isDeletingProject = false
|
||||
self.updateDeleteButton()
|
||||
self.showPreviewToast(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySuccessfulDeletion(
|
||||
project: TravelAlbumPreviewProject,
|
||||
deletedProjectIndex: Int,
|
||||
message: String?
|
||||
) {
|
||||
guard projects.indices.contains(deletedProjectIndex),
|
||||
projects[deletedProjectIndex].id == project.id
|
||||
else {
|
||||
isDeletingProject = false
|
||||
updateDeleteButton()
|
||||
return
|
||||
}
|
||||
|
||||
projects.remove(at: deletedProjectIndex)
|
||||
totalCount = max(0, totalCount - 1)
|
||||
onProjectDeleted?(project.originalMaterialId)
|
||||
|
||||
guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||
deletedProjectIndex: deletedProjectIndex,
|
||||
remainingProjectCount: projects.count
|
||||
) else {
|
||||
dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
selectedKind = .original
|
||||
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original)
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
setPage(currentNodeIndex, animated: false)
|
||||
isDeletingProject = false
|
||||
updateDeleteButton()
|
||||
updateForCurrentNode()
|
||||
if let message, !message.isEmpty {
|
||||
showPreviewToast(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDeleteButton() {
|
||||
deleteButton.isEnabled = !isDeletingProject
|
||||
deleteButton.alpha = isDeletingProject ? 0.55 : 1
|
||||
deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil
|
||||
var configuration = deleteButton.configuration
|
||||
configuration?.showsActivityIndicator = isDeletingProject
|
||||
configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash")
|
||||
configuration?.title = isDeletingProject ? "删除中" : "删除"
|
||||
deleteButton.configuration = configuration
|
||||
}
|
||||
|
||||
@objc private func refreshTapped() {
|
||||
guard let id = currentProject?.originalMaterialId else { return }
|
||||
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
|
||||
@@ -517,8 +562,7 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
||||
let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex
|
||||
? selectedKind
|
||||
: node.kind
|
||||
let quality: TravelAlbumPreviewImageQuality = indexPath.item == currentNodeIndex ? .detail : .preview
|
||||
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original), quality: quality)
|
||||
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original))
|
||||
cell.onSingleTap = { [weak self] in self?.toggleChrome() }
|
||||
cell.onZoomChanged = { [weak self] zoomed in
|
||||
self?.collectionView.isScrollEnabled = !zoomed
|
||||
@@ -565,38 +609,6 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览 Cell 的图片清晰度层级;滑动时使用轻量预览,停稳后升级为高清图。
|
||||
private enum TravelAlbumPreviewImageQuality: String {
|
||||
case preview
|
||||
case detail
|
||||
}
|
||||
|
||||
/// 统一生成预览页图片处理参数,确保展示请求与预取请求共用缓存键。
|
||||
private enum TravelAlbumPreviewImageRequest {
|
||||
static func previewOptions(viewSize: CGSize) -> KingfisherOptionsInfo {
|
||||
options(viewSize: viewSize, sizeMultiplier: 1)
|
||||
}
|
||||
|
||||
static func detailOptions(viewSize: CGSize) -> KingfisherOptionsInfo {
|
||||
options(viewSize: viewSize, sizeMultiplier: 2)
|
||||
}
|
||||
|
||||
private static func options(viewSize: CGSize, sizeMultiplier: CGFloat) -> KingfisherOptionsInfo {
|
||||
let fallbackSize = UIScreen.main.bounds.size
|
||||
let baseSize = viewSize.width > 0 && viewSize.height > 0 ? viewSize : fallbackSize
|
||||
let targetSize = CGSize(
|
||||
width: baseSize.width * sizeMultiplier,
|
||||
height: baseSize.height * sizeMultiplier
|
||||
)
|
||||
return [
|
||||
.processor(DownsamplingImageProcessor(size: targetSize)),
|
||||
.scaleFactor(UIScreen.main.scale),
|
||||
.backgroundDecode,
|
||||
.keepCurrentImageWhileLoading,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||
@@ -607,7 +619,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
private let imageView = UIImageView()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
private var asset: TravelAlbumPreviewAsset?
|
||||
private var requestKey: String?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -624,7 +635,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
asset = nil
|
||||
requestKey = nil
|
||||
retryButton.isHidden = true
|
||||
resetZoom()
|
||||
}
|
||||
@@ -634,15 +644,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
imageView.frame = scrollView.bounds
|
||||
}
|
||||
|
||||
func apply(asset newAsset: TravelAlbumPreviewAsset?, quality: TravelAlbumPreviewImageQuality) {
|
||||
let urlText = quality == .preview ? newAsset?.previewURL : newAsset?.displayURL
|
||||
let newRequestKey = "\(quality.rawValue):\(urlText ?? "")"
|
||||
let isSameRequest = requestKey == newRequestKey && imageView.image != nil
|
||||
func apply(asset newAsset: TravelAlbumPreviewAsset?) {
|
||||
asset = newAsset
|
||||
resetZoom()
|
||||
if !isSameRequest {
|
||||
loadImage(quality: quality, requestKey: newRequestKey)
|
||||
}
|
||||
loadImage()
|
||||
accessibilityLabel = newAsset.map {
|
||||
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
||||
}
|
||||
@@ -704,21 +709,16 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
scrollView.addGestureRecognizer(doubleTap)
|
||||
}
|
||||
|
||||
private func loadImage(quality: TravelAlbumPreviewImageQuality, requestKey: String) {
|
||||
private func loadImage() {
|
||||
retryButton.isHidden = true
|
||||
let text = quality == .preview ? asset?.previewURL : asset?.displayURL
|
||||
let text = asset?.displayURL
|
||||
guard let text, let url = URL(string: text), !text.isEmpty else {
|
||||
imageView.image = nil
|
||||
self.requestKey = nil
|
||||
retryButton.isHidden = false
|
||||
return
|
||||
}
|
||||
self.requestKey = requestKey
|
||||
let options = quality == .preview
|
||||
? TravelAlbumPreviewImageRequest.previewOptions(viewSize: contentView.bounds.size)
|
||||
: TravelAlbumPreviewImageRequest.detailOptions(viewSize: contentView.bounds.size)
|
||||
imageView.kf.setImage(with: url, options: options) { [weak self] result in
|
||||
guard let self, self.requestKey == requestKey else { return }
|
||||
imageView.kf.setImage(with: url) { [weak self] result in
|
||||
guard let self, self.asset?.displayURL == text else { return }
|
||||
if case .failure = result, self.imageView.image == nil {
|
||||
self.retryButton.isHidden = false
|
||||
}
|
||||
@@ -747,9 +747,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
}
|
||||
|
||||
@objc private func retryTapped() {
|
||||
guard let requestKey else { return }
|
||||
let quality: TravelAlbumPreviewImageQuality = requestKey.hasPrefix("preview:") ? .preview : .detail
|
||||
loadImage(quality: quality, requestKey: requestKey)
|
||||
loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user