Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
T

1142 lines
48 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// TravelAlbumPhotoPreviewViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 相册项目定向刷新回调,根据原素材 ID 返回该项目的最新数据。
typealias TravelAlbumPreviewReload = (_ materialId: Int) async throws -> TravelAlbumPreviewProject
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
final class TravelAlbumPhotoPreviewViewController: UIViewController {
private var projects: [TravelAlbumPreviewProject]
private var totalCount: Int
private let configuration: TravelAlbumPreviewConfiguration
private let actionHandler: any TravelAlbumPreviewActionHandling
private let albumId: Int
private let scenicIdProvider: () -> Int
private let aiRetouchAPI: (any TravelAlbumServing)?
private let loadMore: TravelAlbumPreviewLoadMore?
private let reload: TravelAlbumPreviewReload?
private let onProjectDeleted: ((Int) -> Void)?
private let onAIRetouchSubmitted: (() -> Void)?
private let allowsActions: Bool
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 isRefreshingProject = false
private var isDeletingProject = false
private var didApplyInitialPosition = false
private var lastCollectionSize: CGSize = .zero
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 counterContainer = UIView()
private let counterLabel = UILabel()
private let bottomChrome = UIView()
private let divider = UIView()
private let variantControls = UIView()
private let variantSegmentedControl = UISegmentedControl()
private let highResolutionButton = UIButton(type: .system)
private let comparisonButton = UIButton(type: .system)
private let actionStack = UIStackView()
private let deleteButton = UIButton(type: .system)
private let refreshButton = UIButton(type: .system)
private var variantControlsHeightConstraint: Constraint?
private var variantSegmentedControlWidthConstraint: Constraint?
private var didConfigureVariantControls = false
private var showsVariantControls = false
private var variantControlsAnimationGeneration = 0
/// 创建全屏预览页。
init(
projects: [TravelAlbumPreviewProject],
totalCount: Int,
startProjectIndex: Int,
startKind: TravelAlbumPreviewAssetKind = .original,
allowsActions: Bool = true,
configuration: TravelAlbumPreviewConfiguration = .init(),
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
albumId: Int = 0,
scenicIdProvider: @escaping () -> Int = { 0 },
aiRetouchAPI: (any TravelAlbumServing)? = nil,
loadMore: TravelAlbumPreviewLoadMore? = nil,
reload: TravelAlbumPreviewReload? = nil,
onProjectDeleted: ((Int) -> Void)? = nil,
onAIRetouchSubmitted: (() -> Void)? = nil
) {
self.projects = Self.deduplicated(projects)
self.totalCount = max(totalCount, projects.count)
self.configuration = configuration
self.actionHandler = actionHandler
self.albumId = albumId
self.scenicIdProvider = scenicIdProvider
self.aiRetouchAPI = aiRetouchAPI
self.loadMore = loadMore
self.reload = reload
self.onProjectDeleted = onProjectDeleted
self.onAIRetouchSubmitted = onAIRetouchSubmitted
self.allowsActions = allowsActions
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var prefersStatusBarHidden: Bool { false }
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
updateForCurrentNode()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let collectionSize = collectionView.bounds.size
if collectionSize != lastCollectionSize {
lastCollectionSize = collectionSize
collectionView.collectionViewLayout.invalidateLayout()
if didApplyInitialPosition {
setPage(currentNodeIndex, animated: false)
}
}
guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return }
didApplyInitialPosition = true
setPage(currentNodeIndex, animated: false)
}
private func setupUI() {
view.backgroundColor = .black
collectionView.backgroundColor = .black
collectionView.isPagingEnabled = true
collectionView.alwaysBounceHorizontal = nodes.count > 1
collectionView.showsHorizontalScrollIndicator = false
collectionView.contentInsetAdjustmentBehavior = .never
collectionView.accessibilityIdentifier = "travelAlbum.previewCollectionView"
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(
TravelAlbumPreviewImageCell.self,
forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier
)
topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.82)
topChrome.accessibilityIdentifier = "travelAlbum.previewTopChrome"
bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.82)
bottomChrome.accessibilityIdentifier = "travelAlbum.previewBottomChrome"
divider.backgroundColor = UIColor.white.withAlphaComponent(0.12)
var backConfiguration = UIButton.Configuration.plain()
backConfiguration.image = UIImage(systemName: "chevron.down")
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
pointSize: 21,
weight: .semibold
)
backConfiguration.baseForegroundColor = .white
backConfiguration.contentInsets = .zero
closeButton.configuration = backConfiguration
closeButton.accessibilityLabel = "关闭图片预览"
closeButton.accessibilityIdentifier = "travelAlbum.previewBackButton"
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
titleLabel.textColor = .white
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
sizeLabel.textAlignment = .center
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
counterLabel.textAlignment = .center
counterLabel.numberOfLines = 1
counterLabel.accessibilityIdentifier = "travelAlbum.previewCounterLabel"
counterLabel.setContentHuggingPriority(.required, for: .horizontal)
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
counterContainer.backgroundColor = UIColor(white: 0.11, alpha: 1)
counterContainer.layer.cornerRadius = 17
counterContainer.clipsToBounds = true
variantSegmentedControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2)
variantSegmentedControl.setTitleTextAttributes(
[.foregroundColor: UIColor.white.withAlphaComponent(0.7)],
for: .normal
)
variantSegmentedControl.setTitleTextAttributes(
[.foregroundColor: UIColor.white],
for: .selected
)
variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl"
variantSegmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
actionStack.axis = .horizontal
actionStack.distribution = .fillEqually
actionStack.alignment = .fill
actionStack.spacing = 10
actionStack.isLayoutMarginsRelativeArrangement = true
actionStack.directionalLayoutMargins = NSDirectionalEdgeInsets(
top: 9,
leading: 0,
bottom: 9,
trailing: 0
)
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
configureHighResolutionButton()
configureComparisonButton()
configureActions()
actionStack.isHidden = !allowsActions
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(counterContainer)
counterContainer.addSubview(counterLabel)
view.addSubview(bottomChrome)
bottomChrome.addSubview(divider)
bottomChrome.addSubview(variantControls)
variantControls.addSubview(variantSegmentedControl)
variantControls.addSubview(highResolutionButton)
bottomChrome.addSubview(actionStack)
view.addSubview(comparisonButton)
topChrome.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(72)
}
closeButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.bottom.equalToSuperview().offset(-14)
make.size.equalTo(44)
}
counterContainer.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(closeButton)
make.width.greaterThanOrEqualTo(48)
make.height.equalTo(34)
}
counterLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
titleStack.snp.makeConstraints { make in
make.centerY.equalTo(closeButton)
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
make.trailing.lessThanOrEqualTo(counterContainer.snp.leading).offset(-8)
make.centerX.equalToSuperview()
make.width.lessThanOrEqualTo(220)
}
bottomChrome.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
divider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
variantControls.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom)
make.leading.trailing.equalToSuperview()
variantControlsHeightConstraint = make.height.equalTo(56).constraint
}
variantSegmentedControl.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(20)
make.centerY.equalToSuperview()
variantSegmentedControlWidthConstraint = make.width.equalTo(124).constraint
make.height.equalTo(32)
make.trailing.lessThanOrEqualTo(highResolutionButton.snp.leading).offset(-12)
}
highResolutionButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(18)
make.centerY.equalToSuperview()
make.width.equalTo(116)
make.height.equalTo(40)
}
actionStack.snp.makeConstraints { make in
make.top.equalTo(variantControls.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(70)
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
comparisonButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomChrome.snp.top).offset(-12)
make.size.equalTo(48)
}
}
private func configureHighResolutionButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "查看高清图"
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = UIColor(white: 0.18, alpha: 1)
configuration.background.cornerRadius = 20
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 15, weight: .medium)
return attributes
}
highResolutionButton.configuration = configuration
highResolutionButton.accessibilityLabel = "查看高清图"
highResolutionButton.accessibilityIdentifier = "travelAlbum.previewHighResolutionButton"
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
}
private func configureComparisonButton() {
var configuration = UIButton.Configuration.filled()
configuration.image = UIImage(named: "travel_album_before_after")?.withRenderingMode(.alwaysTemplate)
configuration.contentInsets = .zero
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.94)
configuration.background.cornerRadius = 24
configuration.background.strokeColor = UIColor(hex: 0x263244)
configuration.background.strokeWidth = 1
comparisonButton.configuration = configuration
comparisonButton.accessibilityLabel = "前后对比"
comparisonButton.accessibilityHint = "对比当前效果图与原图"
comparisonButton.accessibilityIdentifier = "travelAlbum.previewComparisonButton"
comparisonButton.layer.shadowColor = UIColor.black.cgColor
comparisonButton.layer.shadowOpacity = 0.28
comparisonButton.layer.shadowRadius = 8
comparisonButton.layer.shadowOffset = CGSize(width: 0, height: 3)
comparisonButton.isHidden = true
comparisonButton.addTarget(self, action: #selector(comparisonTapped), for: .touchUpInside)
}
private func configureActions() {
let aiButton = makeActionButton(
title: "AI修图",
systemName: "wand.and.stars",
foregroundColor: .white,
backgroundColor: UIColor(hex: 0x1677FF),
strokeColor: UIColor(hex: 0x4096FF)
)
aiButton.accessibilityIdentifier = "travelAlbum.previewAIRetouchButton"
let deleteConfiguration = makeActionButton(
title: "删除",
systemName: "trash",
foregroundColor: UIColor(hex: 0xFF6B6B),
backgroundColor: UIColor(hex: 0x211719),
strokeColor: UIColor(hex: 0x4A292D)
).configuration
deleteButton.configuration = deleteConfiguration
deleteButton.accessibilityLabel = "删除"
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
refreshButton.configuration = makeActionButton(
title: "刷新",
systemName: "arrow.clockwise",
foregroundColor: UIColor.white.withAlphaComponent(0.92),
backgroundColor: UIColor(hex: 0x1C1C1E),
strokeColor: UIColor.white.withAlphaComponent(0.1)
).configuration
refreshButton.accessibilityLabel = "刷新"
refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton"
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,
foregroundColor: UIColor,
backgroundColor: UIColor,
strokeColor: UIColor
) -> UIButton {
var configuration = UIButton.Configuration.filled()
configuration.title = title
configuration.image = UIImage(systemName: systemName)
configuration.imagePlacement = .leading
configuration.imagePadding = 7
configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
pointSize: 18,
weight: .semibold
)
configuration.baseForegroundColor = foregroundColor
configuration.baseBackgroundColor = backgroundColor
configuration.background.cornerRadius = 14
configuration.background.strokeColor = strokeColor
configuration.background.strokeWidth = 1
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 14, weight: .semibold)
return attributes
}
let button = UIButton(configuration: configuration)
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)) 张"
highResolutionButton.isEnabled = asset != nil
highResolutionButton.alpha = asset == nil ? 0.45 : 1
rebuildTabs()
updateComparisonButton()
loadMoreIfNeeded(projectIndex: node.projectIndex)
}
private func updateComparisonButton() {
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
let shouldShow = chromeVisible && hasComparison
comparisonButton.isHidden = !hasComparison
comparisonButton.isUserInteractionEnabled = shouldShow
comparisonButton.alpha = shouldShow ? 1 : 0
comparisonButton.transform = shouldShow
? .identity
: CGAffineTransform(translationX: 0, y: 10)
}
private func rebuildTabs() {
while variantSegmentedControl.numberOfSegments > 0 {
variantSegmentedControl.removeSegment(at: 0, animated: false)
}
guard let project = currentProject, project.hasVariants else {
setVariantControlsVisible(false)
return
}
let activeKind = currentAsset?.kind ?? .original
for (index, asset) in project.orderedAssets.enumerated() {
variantSegmentedControl.insertSegment(withTitle: asset.kind.title, at: index, animated: false)
}
let controlWidth: CGFloat
switch project.orderedAssets.count {
case 4: controlWidth = 220
case 3: controlWidth = 180
default: controlWidth = 124
}
variantSegmentedControlWidthConstraint?.update(offset: controlWidth)
variantSegmentedControl.selectedSegmentIndex = project.orderedAssets.firstIndex {
$0.kind == activeKind
} ?? 0
setVariantControlsVisible(true)
}
private func setVariantControlsVisible(_ visible: Bool) {
let visibilityChanged = didConfigureVariantControls && showsVariantControls != visible
didConfigureVariantControls = true
showsVariantControls = visible
variantControlsAnimationGeneration += 1
let animationGeneration = variantControlsAnimationGeneration
let shouldAnimate = visibilityChanged
&& chromeVisible
&& view.window != nil
&& UIView.areAnimationsEnabled
&& !UIAccessibility.isReduceMotionEnabled
view.layoutIfNeeded()
variantControls.isHidden = false
variantSegmentedControl.isHidden = false
highResolutionButton.isHidden = false
variantControlsHeightConstraint?.update(offset: visible ? 56 : 0)
guard shouldAnimate else {
variantControls.alpha = 1
variantControls.transform = .identity
view.layoutIfNeeded()
variantControls.isHidden = !visible
variantSegmentedControl.isHidden = !visible
highResolutionButton.isHidden = !visible
updateHiddenBottomChromePositionIfNeeded()
return
}
if visible {
variantControls.alpha = 0
variantControls.transform = CGAffineTransform(translationX: 0, y: 8)
}
UIView.animate(
withDuration: 0.24,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction],
animations: {
self.variantControls.alpha = visible ? 1 : 0
self.variantControls.transform = visible
? .identity
: CGAffineTransform(translationX: 0, y: 8)
self.view.layoutIfNeeded()
},
completion: { [weak self] finished in
guard let self,
finished,
self.variantControlsAnimationGeneration == animationGeneration
else { return }
self.variantControls.isHidden = !visible
self.variantSegmentedControl.isHidden = !visible
self.highResolutionButton.isHidden = !visible
self.variantControls.alpha = 1
self.variantControls.transform = .identity
self.updateHiddenBottomChromePositionIfNeeded()
}
)
}
private func updateHiddenBottomChromePositionIfNeeded() {
guard !chromeVisible else { return }
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
}
@objc private func segmentChanged(_ sender: UISegmentedControl) {
guard let project = currentProject,
project.orderedAssets.indices.contains(sender.selectedSegmentIndex)
else { return }
let kind = project.orderedAssets[sender.selectedSegmentIndex].kind
guard 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()
return
}
currentNodeIndex = index
if oldProjectIndex != currentNode?.projectIndex {
selectedKind = .original
resetProjectCellToOriginalIfNeeded(at: oldNodeIndex)
}
collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() }
updateForCurrentNode()
}
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))
}
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()
}
}
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 hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
if chromeVisible && hasComparison {
comparisonButton.isHidden = false
}
comparisonButton.isUserInteractionEnabled = chromeVisible && hasComparison
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)
self.comparisonButton.alpha = self.chromeVisible && hasComparison ? 1 : 0
self.comparisonButton.transform = self.chromeVisible && hasComparison
? .identity
: CGAffineTransform(translationX: 0, y: 10)
}
UIView.animate(
withDuration: reduceMotion ? 0.12 : 0.22,
delay: 0,
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
animations: animations,
completion: { [weak self] _ in
guard let self else { return }
self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison
}
)
}
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 highResolutionTapped() {
guard chromeVisible else { return }
toggleChrome()
}
@objc private func comparisonTapped() {
guard presentedViewController == nil,
let project = currentProject,
let content = project.comparisonContent(for: currentAsset?.kind ?? .original)
else { return }
let controller = BeforeAfterComparisonViewController(
viewModel: BeforeAfterComparisonViewModel(content: content)
)
present(controller, animated: true)
}
@objc private func aiTapped() {
guard presentedViewController == nil else { return }
guard let project = currentProject else { return }
guard let aiRetouchAPI else {
showPreviewToast("AI修图功能暂不可用")
return
}
let scenicId = scenicIdProvider()
guard scenicId > 0 else {
showPreviewToast("请先选择景区")
return
}
let selectedKind = currentAsset?.kind ?? .original
guard let workflow = project.aiRetouchWorkflow(albumId: albumId, selectedKind: selectedKind) else {
showPreviewToast("当前图片不支持AI修图")
return
}
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: TravelAlbumAIRetouchTemplateViewModel(
scenicId: scenicId,
workflow: workflow
),
api: aiRetouchAPI,
onSubmitted: { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.onAIRetouchSubmitted?() }
}
)
present(controller, animated: true)
}
@objc private func deleteTapped() {
guard currentProject != nil, !isDeletingProject else { return }
let alert = UIAlertController(
title: "删除整个项目",
message: "将同时删除原图及其全部关联图片,是否继续?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
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() {
reloadProjects(showGlobalLoading: true, forceRefreshImage: true)
}
private func reloadProjects(showGlobalLoading: Bool, forceRefreshImage: Bool) {
guard !isRefreshingProject else { return }
guard let reload else { return }
isRefreshingProject = true
updateRefreshButton()
if showGlobalLoading { GlobalLoadingManager.shared.show() }
let currentProjectId = currentProject?.id
let currentProjectIndex = currentNode?.projectIndex ?? 0
let kind = currentAsset?.kind ?? .original
Task { [weak self] in
defer {
if showGlobalLoading { GlobalLoadingManager.shared.hide() }
}
guard let self else { return }
do {
guard let currentProjectId else {
self.isRefreshingProject = false
self.updateRefreshButton()
return
}
let project = try await reload(currentProjectId)
self.applySuccessfulReload(
project: project,
currentProjectId: currentProjectId,
fallbackProjectIndex: currentProjectIndex,
kind: kind,
forceRefreshImage: forceRefreshImage
)
} catch is CancellationError {
self.isRefreshingProject = false
self.updateRefreshButton()
} catch {
self.isRefreshingProject = false
self.updateRefreshButton()
}
}
}
private func applySuccessfulReload(
project refreshedProject: TravelAlbumPreviewProject,
currentProjectId: Int,
fallbackProjectIndex: Int,
kind: TravelAlbumPreviewAssetKind,
forceRefreshImage: Bool
) {
guard !projects.isEmpty else {
isRefreshingProject = false
updateRefreshButton()
dismiss(animated: true)
return
}
let targetProjectIndex = projects.firstIndex { $0.id == currentProjectId }
?? min(max(0, fallbackProjectIndex), projects.count - 1)
projects[targetProjectIndex] = refreshedProject
let resolvedKind = refreshedProject.asset(for: kind) == nil ? .original : kind
selectedKind = resolvedKind
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind)
collectionView.reloadData()
collectionView.layoutIfNeeded()
setPage(currentNodeIndex, animated: false)
updateForCurrentNode()
if forceRefreshImage { forceRefreshCurrentImage() }
isRefreshingProject = false
updateRefreshButton()
}
private func forceRefreshCurrentImage() {
let indexPath = IndexPath(item: currentNodeIndex, section: 0)
guard let cell = collectionView.cellForItem(at: indexPath) as? TravelAlbumPreviewImageCell else { return }
cell.apply(asset: currentAsset, forceRefresh: true)
}
private func updateRefreshButton() {
refreshButton.isEnabled = !isRefreshingProject
refreshButton.alpha = isRefreshingProject ? 0.55 : 1
refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil
var configuration = refreshButton.configuration
configuration?.showsActivityIndicator = isRefreshingProject
configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise")
configuration?.title = isRefreshingProject ? "刷新中" : "刷新"
refreshButton.configuration = configuration
}
}
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
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
}
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,使用 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?
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
retryButton.isHidden = true
resetZoom()
}
func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) {
asset = newAsset
resetZoom()
loadImage(forceRefresh: forceRefresh)
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
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
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() }
imageView.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
make.height.equalTo(scrollView.frameLayoutGuide)
}
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(forceRefresh: Bool = false) {
retryButton.isHidden = true
let text = asset?.displayURL
guard let text, let url = URL(string: text), !text.isEmpty else {
imageView.image = nil
retryButton.isHidden = false
return
}
let options: KingfisherOptionsInfo? = forceRefresh ? [.forceRefresh] : nil
imageView.kf.setImage(with: url, options: options) { [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
}
}
}
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() {
loadImage(forceRefresh: true)
}
}
/// 带内边距的轻量 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)
}
}