768 lines
31 KiB
Swift
768 lines
31 KiB
Swift
//
|
||
// 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 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 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 let deleteButton = UIButton(type: .system)
|
||
private var tabHeightConstraint: Constraint?
|
||
|
||
/// 创建全屏预览页。
|
||
init(
|
||
projects: [TravelAlbumPreviewProject],
|
||
totalCount: Int,
|
||
startProjectIndex: Int,
|
||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||
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)
|
||
}
|
||
|
||
@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()
|
||
}
|
||
|
||
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.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 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)
|
||
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()
|
||
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 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 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() {
|
||
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
|
||
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?) {
|
||
asset = newAsset
|
||
resetZoom()
|
||
loadImage()
|
||
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() {
|
||
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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// 带内边距的轻量 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)
|
||
}
|
||
}
|