feat: add iOS travel album photo preview

This commit is contained in:
2026-08-10 10:07:19 +08:00
parent dcdab3cff7
commit 99fbad3e97
5 changed files with 1101 additions and 2 deletions
@@ -0,0 +1,174 @@
//
// TravelAlbumPreviewModels.swift
// suixinkan
//
import Foundation
/// 相册预览页横向滑动策略,由调用方通过配置注入。
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
/// 横滑只切换原图项目,关联图通过 Tab 切换。
case projectsOnly
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
case includeVariants
}
/// 相册预览页配置。
struct TravelAlbumPreviewConfiguration: Sendable {
let swipeMode: TravelAlbumPreviewSwipeMode
/// 创建预览配置,默认只按原图项目分页。
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
self.swipeMode = swipeMode
}
}
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
case original
case retouched
case atmosphere
case cover
var title: String {
switch self {
case .original: "原图"
case .retouched: "精修后"
case .atmosphere: "氛围感"
case .cover: "封面"
}
}
}
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
let id: String
let kind: TravelAlbumPreviewAssetKind
let fileURL: String
let coverURL: String
let fileName: String
let fileSize: Int
/// 实际用于展示的地址,优先使用原图地址。
var displayURL: String {
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
}
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
var previewURL: String {
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
}
}
/// 一张原图及其所有关联图片组成的预览项目。
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
let originalMaterialId: Int
let assets: [TravelAlbumPreviewAsset]
var id: Int { originalMaterialId }
/// 按产品约定顺序返回实际存在的图片。
var orderedAssets: [TravelAlbumPreviewAsset] {
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
}
/// 是否存在原图之外的关联图片。
var hasVariants: Bool {
orderedAssets.contains { $0.kind != .original }
}
/// 返回指定类型的图片。
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
assets.first { $0.kind == kind }
}
/// 将当前素材映射为仅含原图的预览项目;新接口接入后替换此适配层即可。
init(material: TravelAlbumMaterial) {
originalMaterialId = material.id
assets = [
TravelAlbumPreviewAsset(
id: "original-\(material.id)",
kind: .original,
fileURL: material.fileUrl,
coverURL: material.coverUrl,
fileName: material.fileName,
fileSize: material.fileSize
),
]
}
/// 创建包含关联图片的项目,主要供适配器和测试使用。
init(originalMaterialId: Int, assets: [TravelAlbumPreviewAsset]) {
self.originalMaterialId = originalMaterialId
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
}
}
/// 预览分页节点,记录当前图片所属项目及类型。
struct TravelAlbumPreviewNode: Sendable, Hashable {
let projectIndex: Int
let kind: TravelAlbumPreviewAssetKind
}
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
enum TravelAlbumPreviewNavigator {
/// 根据滑动模式生成页面节点。
static func nodes(
projects: [TravelAlbumPreviewProject],
mode: TravelAlbumPreviewSwipeMode
) -> [TravelAlbumPreviewNode] {
projects.enumerated().flatMap { index, project in
switch mode {
case .projectsOnly:
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
case .includeVariants:
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
}
}
}
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
static func backwardTargetIndex(
nodes: [TravelAlbumPreviewNode],
currentIndex: Int
) -> Int {
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
let current = nodes[currentIndex]
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
return nodes.firstIndex {
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
} ?? currentIndex - 1
}
}
/// 预览操作执行结果,便于后续替换真实业务接口。
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
case success(String?)
case failure(String)
case unavailable(String)
}
/// 预览页底部操作协议,所有操作均以原素材项目 ID 为目标。
protocol TravelAlbumPreviewActionHandling: AnyObject {
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
}
/// 新接口接入前的占位操作实现,不修改任何业务数据。
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("AI修复功能待接口接入")
}
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("项目删除接口待接入")
}
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("关联图片刷新接口待接入")
}
}
@@ -137,6 +137,11 @@ final class TravelAlbumDetailViewModel {
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials)
}
/// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。
var currentPhotoCount: Int {
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
}
/// 切换素材 tab。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
@@ -11,6 +11,7 @@ import UIKit
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing
private let previewConfiguration: TravelAlbumPreviewConfiguration
private let contentView = UIView()
private let infoCard = TravelAlbumInfoCard()
@@ -27,9 +28,14 @@ final class TravelAlbumDetailViewController: BaseViewController {
private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
init(
albumId: Int,
api: (any TravelAlbumServing)? = nil,
previewConfiguration: TravelAlbumPreviewConfiguration = .init()
) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.previewConfiguration = previewConfiguration
super.init(nibName: nil, bundle: nil)
}
@@ -413,12 +419,36 @@ final class TravelAlbumDetailViewController: BaseViewController {
)
navigationController?.pushViewController(controller, animated: true)
}
private func presentPreview(startingWith material: TravelAlbumMaterial) {
guard let startIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) else { return }
let previewViewModel = viewModel
let previewAPI = api
let controller = TravelAlbumPhotoPreviewViewController(
projects: viewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
totalCount: viewModel.currentPhotoCount,
startProjectIndex: startIndex,
configuration: previewConfiguration,
loadMore: {
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
return (
previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
previewViewModel.currentPhotoCount
)
}
)
present(controller, animated: true)
}
}
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.toggleMaterialSelection(material)
if viewModel.isSelectionMode {
viewModel.toggleMaterialSelection(material)
} else {
presentPreview(startingWith: material)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -0,0 +1,768 @@
//
// TravelAlbumPhotoPreviewViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
final class TravelAlbumPhotoPreviewViewController: UIViewController {
private var projects: [TravelAlbumPreviewProject]
private var totalCount: Int
private let configuration: TravelAlbumPreviewConfiguration
private let actionHandler: any TravelAlbumPreviewActionHandling
private let loadMore: TravelAlbumPreviewLoadMore?
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 didApplyInitialPosition = false
private var lastCollectionSize: CGSize = .zero
private var previewPrefetcher: ImagePrefetcher?
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private let topChrome = UIView()
private let closeButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let sizeLabel = UILabel()
private let counterLabel = UILabel()
private let bottomChrome = UIView()
private let divider = UIView()
private let tabStack = UIStackView()
private let actionStack = UIStackView()
private var tabHeightConstraint: Constraint?
/// 创建全屏预览页。
init(
projects: [TravelAlbumPreviewProject],
totalCount: Int,
startProjectIndex: Int,
configuration: TravelAlbumPreviewConfiguration = .init(),
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
loadMore: TravelAlbumPreviewLoadMore? = nil
) {
self.projects = Self.deduplicated(projects)
self.totalCount = max(totalCount, projects.count)
self.configuration = configuration
self.actionHandler = actionHandler
self.loadMore = loadMore
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var prefersStatusBarHidden: Bool { true }
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
updateForCurrentNode()
prefetchAdjacentImages()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let collectionSize = collectionView.bounds.size
if collectionSize != lastCollectionSize {
lastCollectionSize = collectionSize
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() {
view.backgroundColor = .black
collectionView.backgroundColor = .black
collectionView.isPagingEnabled = true
collectionView.alwaysBounceHorizontal = nodes.count > 1
collectionView.showsHorizontalScrollIndicator = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(
TravelAlbumPreviewImageCell.self,
forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier
)
topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.72)
bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.78)
divider.backgroundColor = UIColor.white.withAlphaComponent(0.2)
closeButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)), for: .normal)
closeButton.tintColor = .white
closeButton.accessibilityLabel = "关闭图片预览"
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
titleLabel.textColor = .white
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.65)
sizeLabel.font = .systemFont(ofSize: 11, weight: .regular)
sizeLabel.textAlignment = .center
counterLabel.textColor = .white
counterLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
counterLabel.textAlignment = .right
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
tabStack.alignment = .fill
actionStack.axis = .horizontal
actionStack.distribution = .fillEqually
actionStack.alignment = .fill
configureActions()
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
titleStack.axis = .vertical
titleStack.spacing = 3
titleStack.alignment = .fill
view.addSubview(collectionView)
view.addSubview(topChrome)
topChrome.addSubview(closeButton)
topChrome.addSubview(titleStack)
topChrome.addSubview(counterLabel)
view.addSubview(bottomChrome)
bottomChrome.addSubview(divider)
bottomChrome.addSubview(tabStack)
bottomChrome.addSubview(actionStack)
collectionView.snp.makeConstraints { $0.edges.equalToSuperview() }
topChrome.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(64)
}
closeButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.bottom.equalToSuperview().offset(-10)
make.size.equalTo(44)
}
counterLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(closeButton)
make.width.greaterThanOrEqualTo(52)
}
titleStack.snp.makeConstraints { make in
make.centerY.equalTo(closeButton)
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
make.trailing.lessThanOrEqualTo(counterLabel.snp.leading).offset(-8)
make.centerX.equalToSuperview()
make.width.lessThanOrEqualTo(220)
}
bottomChrome.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
divider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
tabStack.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom)
make.leading.trailing.equalToSuperview().inset(12)
tabHeightConstraint = make.height.equalTo(0).constraint
}
actionStack.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(8)
make.height.equalTo(70)
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
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 refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white)
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
[aiButton, deleteButton, refreshButton].forEach(actionStack.addArrangedSubview)
}
private func makeActionButton(title: String, systemName: String, color: UIColor) -> UIButton {
var configuration = UIButton.Configuration.plain()
configuration.title = title
configuration.image = UIImage(systemName: systemName)
configuration.imagePlacement = .top
configuration.imagePadding = 5
configuration.baseForegroundColor = color
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)
let button = UIButton(configuration: configuration)
button.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
button.accessibilityLabel = title
return button
}
private func makeLayout() -> UICollectionViewLayout {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
return layout
}
private var currentNode: TravelAlbumPreviewNode? {
nodes.indices.contains(currentNodeIndex) ? nodes[currentNodeIndex] : nil
}
private var currentProject: TravelAlbumPreviewProject? {
guard let node = currentNode, projects.indices.contains(node.projectIndex) else { return nil }
return projects[node.projectIndex]
}
private var currentAsset: TravelAlbumPreviewAsset? {
guard let node = currentNode, let project = currentProject else { return nil }
let kind = configuration.swipeMode == .projectsOnly ? selectedKind : node.kind
return project.asset(for: kind) ?? project.asset(for: .original)
}
private func updateForCurrentNode() {
guard let node = currentNode else { return }
let asset = currentAsset
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
rebuildTabs()
loadMoreIfNeeded(projectIndex: node.projectIndex)
}
private func rebuildTabs() {
tabStack.arrangedSubviews.forEach {
tabStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
guard let project = currentProject, project.hasVariants else {
tabStack.isHidden = true
tabHeightConstraint?.update(offset: 0)
return
}
tabStack.isHidden = false
tabHeightConstraint?.update(offset: 44)
let activeKind = currentAsset?.kind ?? .original
for asset in project.orderedAssets {
let button = UIButton(type: .system)
button.tag = asset.kind.rawValue
button.setTitle(asset.kind.title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 13, weight: asset.kind == activeKind ? .semibold : .medium)
button.setTitleColor(asset.kind == activeKind ? .white : UIColor.white.withAlphaComponent(0.55), for: .normal)
button.backgroundColor = asset.kind == activeKind ? UIColor.white.withAlphaComponent(0.12) : .clear
button.layer.cornerRadius = 8
button.accessibilityLabel = asset.kind.title
button.accessibilityTraits = asset.kind == activeKind ? [.button, .selected] : .button
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
tabStack.addArrangedSubview(button)
}
view.layoutIfNeeded()
if !chromeVisible {
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
}
}
@objc private func tabTapped(_ sender: UIButton) {
guard let kind = TravelAlbumPreviewAssetKind(rawValue: sender.tag),
let node = currentNode,
currentProject?.asset(for: kind) != nil
else { return }
switch configuration.swipeMode {
case .projectsOnly:
selectedKind = kind
collectionView.reloadItems(at: [IndexPath(item: currentNodeIndex, section: 0)])
updateForCurrentNode()
case .includeVariants:
guard let target = nodes.firstIndex(where: { $0.projectIndex == node.projectIndex && $0.kind == kind }) else { return }
currentNodeIndex = target
setPage(target, animated: true)
updateForCurrentNode()
}
}
private func didChangePage(to index: Int) {
guard nodes.indices.contains(index) else { return }
let oldNodeIndex = currentNodeIndex
let oldProjectIndex = currentNode?.projectIndex
guard oldNodeIndex != index else {
updateForCurrentNode()
upgradeCurrentCellToDetailImage()
prefetchAdjacentImages()
return
}
currentNodeIndex = index
if oldProjectIndex != currentNode?.projectIndex {
selectedKind = .original
resetProjectCellToOriginalIfNeeded(at: oldNodeIndex)
}
collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() }
updateForCurrentNode()
upgradeCurrentCellToDetailImage()
prefetchAdjacentImages()
}
private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) {
guard configuration.swipeMode == .projectsOnly,
nodes.indices.contains(nodeIndex),
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()
}
private func setPage(_ index: Int, animated: Bool) {
guard collectionView.bounds.width > 0, nodes.indices.contains(index) else { return }
collectionView.setContentOffset(CGPoint(x: CGFloat(index) * collectionView.bounds.width, y: 0), animated: animated)
}
private func rebuildNodes(keepingProjectIndex: Int, kind: TravelAlbumPreviewAssetKind) {
nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: configuration.swipeMode)
currentNodeIndex = nodes.firstIndex {
$0.projectIndex == keepingProjectIndex &&
(configuration.swipeMode == .projectsOnly || $0.kind == kind)
} ?? nodes.firstIndex { $0.projectIndex == keepingProjectIndex } ?? 0
}
private func loadMoreIfNeeded(projectIndex: Int) {
guard projectIndex >= projects.count - 4,
projects.count < totalCount,
!isLoadingMore,
let loadMore
else { return }
isLoadingMore = true
let currentProjectId = currentProject?.id
let kind = currentAsset?.kind ?? .original
Task { [weak self] in
let result = await loadMore()
guard let self else { return }
let incoming = Self.deduplicated(result.projects)
guard incoming.count > self.projects.count else {
self.totalCount = max(result.totalCount, incoming.count)
self.isLoadingMore = false
return
}
self.projects = incoming
self.totalCount = max(result.totalCount, incoming.count)
let projectIndex = currentProjectId.flatMap { id in incoming.firstIndex { $0.id == id } } ?? 0
self.rebuildNodes(keepingProjectIndex: projectIndex, kind: kind)
self.collectionView.reloadData()
self.collectionView.layoutIfNeeded()
self.setPage(self.currentNodeIndex, animated: false)
self.isLoadingMore = false
self.updateForCurrentNode()
self.upgradeCurrentCellToDetailImage()
self.prefetchAdjacentImages()
}
}
private static func deduplicated(_ projects: [TravelAlbumPreviewProject]) -> [TravelAlbumPreviewProject] {
var seen = Set<Int>()
return projects.filter { seen.insert($0.id).inserted }
}
private func toggleChrome() {
chromeVisible.toggle()
let reduceMotion = UIAccessibility.isReduceMotionEnabled
let animations = {
self.topChrome.alpha = self.chromeVisible ? 1 : 0
self.bottomChrome.alpha = self.chromeVisible ? 1 : 0
self.topChrome.transform = self.chromeVisible
? .identity
: CGAffineTransform(translationX: 0, y: -self.topChrome.bounds.height)
self.bottomChrome.transform = self.chromeVisible
? .identity
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
}
UIView.animate(
withDuration: reduceMotion ? 0.12 : 0.22,
delay: 0,
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
animations: animations
)
}
private func performAction(_ operation: @escaping () async -> TravelAlbumPreviewActionResult) {
Task { [weak self] in
guard let self else { return }
let result = await operation()
switch result {
case .success(let message):
if let message, !message.isEmpty { self.showPreviewToast(message) }
case .failure(let message), .unavailable(let message):
self.showPreviewToast(message)
}
}
}
private func showPreviewToast(_ message: String) {
let label = TravelAlbumPreviewToastLabel()
label.text = message
label.textColor = .white
label.font = .systemFont(ofSize: 14, weight: .medium)
label.textAlignment = .center
label.backgroundColor = UIColor(white: 0.12, alpha: 0.94)
label.layer.cornerRadius = 10
label.clipsToBounds = true
label.alpha = 0
view.addSubview(label)
label.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-110)
make.width.lessThanOrEqualToSuperview().inset(32)
}
UIView.animate(withDuration: 0.18, animations: { label.alpha = 1 }) { _ in
UIView.animate(withDuration: 0.2, delay: 1.5, options: .curveEaseIn, animations: { label.alpha = 0 }) { _ in
label.removeFromSuperview()
}
}
}
@objc private func closeTapped() {
dismiss(animated: true)
}
@objc private func aiTapped() {
guard let id = currentProject?.originalMaterialId else { return }
performAction { [actionHandler] in await actionHandler.requestAIRetouch(originalMaterialId: id) }
}
@objc private func deleteTapped() {
guard let id = currentProject?.originalMaterialId else { return }
let alert = UIAlertController(
title: "删除整个项目",
message: "将同时删除原图及其全部关联图片,是否继续?",
preferredStyle: .alert
)
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)
}
})
present(alert, animated: true)
}
@objc private func refreshTapped() {
guard let id = currentProject?.originalMaterialId else { return }
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
}
}
extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
nodes.count
}
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumPreviewImageCell
let node = nodes[indexPath.item]
let project = projects[node.projectIndex]
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.onSingleTap = { [weak self] in self?.toggleChrome() }
cell.onZoomChanged = { [weak self] zoomed in
self?.collectionView.isScrollEnabled = !zoomed
}
return cell
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
collectionView.bounds.size
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
dragStartIndex = currentNodeIndex
}
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
guard configuration.swipeMode == .includeVariants, scrollView.bounds.width > 0 else { return }
let proposed = Int(round(targetContentOffset.pointee.x / scrollView.bounds.width))
guard proposed < dragStartIndex else { return }
let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: dragStartIndex)
targetContentOffset.pointee.x = CGFloat(target) * scrollView.bounds.width
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updatePageFromOffset()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
updatePageFromOffset()
}
private func updatePageFromOffset() {
guard collectionView.bounds.width > 0 else { return }
let index = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
didChangePage(to: min(max(0, index), max(0, nodes.count - 1)))
}
}
/// 预览 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"
var onSingleTap: (() -> Void)?
var onZoomChanged: ((Bool) -> Void)?
private let scrollView = UIScrollView()
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)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
asset = nil
requestKey = nil
retryButton.isHidden = true
resetZoom()
}
override func layoutSubviews() {
super.layoutSubviews()
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
asset = newAsset
resetZoom()
if !isSameRequest {
loadImage(quality: quality, requestKey: newRequestKey)
}
accessibilityLabel = newAsset.map {
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
}
}
func resetZoom() {
scrollView.setZoomScale(1, animated: false)
scrollView.contentOffset = .zero
scrollView.panGestureRecognizer.isEnabled = false
onZoomChanged?(false)
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerImage()
let isZoomed = scrollView.zoomScale > 1.01
if scrollView.panGestureRecognizer.isEnabled != isZoomed {
scrollView.panGestureRecognizer.isEnabled = isZoomed
}
onZoomChanged?(isZoomed)
}
private func setupUI() {
backgroundColor = .black
scrollView.backgroundColor = .black
scrollView.delegate = self
scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 4
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.panGestureRecognizer.isEnabled = false
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
imageView.kf.indicatorType = .activity
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
retryButton.setTitleColor(UIColor.white.withAlphaComponent(0.82), for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
retryButton.isHidden = true
retryButton.accessibilityLabel = "图片加载失败,重新加载"
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
contentView.addSubview(scrollView)
scrollView.addSubview(imageView)
contentView.addSubview(retryButton)
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
retryButton.snp.makeConstraints { $0.center.equalToSuperview() }
let singleTap = UITapGestureRecognizer(target: self, action: #selector(singleTapped))
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:)))
doubleTap.numberOfTapsRequired = 2
singleTap.require(toFail: doubleTap)
scrollView.addGestureRecognizer(singleTap)
scrollView.addGestureRecognizer(doubleTap)
}
private func loadImage(quality: TravelAlbumPreviewImageQuality, requestKey: String) {
retryButton.isHidden = true
let text = quality == .preview ? asset?.previewURL : 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 }
if case .failure = result, self.imageView.image == nil {
self.retryButton.isHidden = false
}
}
}
private func centerImage() {
let horizontal = max(0, (scrollView.bounds.width - imageView.frame.width) / 2)
let vertical = max(0, (scrollView.bounds.height - imageView.frame.height) / 2)
scrollView.contentInset = UIEdgeInsets(top: vertical, left: horizontal, bottom: vertical, right: horizontal)
}
@objc private func singleTapped() {
onSingleTap?()
}
@objc private func doubleTapped(_ gesture: UITapGestureRecognizer) {
if scrollView.zoomScale > 1.01 {
scrollView.setZoomScale(1, animated: true)
return
}
let point = gesture.location(in: imageView)
let width = scrollView.bounds.width / 2
let height = scrollView.bounds.height / 2
scrollView.zoom(to: CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height), animated: true)
}
@objc private func retryTapped() {
guard let requestKey else { return }
let quality: TravelAlbumPreviewImageQuality = requestKey.hasPrefix("preview:") ? .preview : .detail
loadImage(quality: quality, requestKey: requestKey)
}
}
/// 带内边距的轻量 Toast 标签。
private final class TravelAlbumPreviewToastLabel: UILabel {
private let insets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14)
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom)
}
}