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

898 lines
37 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)
/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。
typealias TravelAlbumPreviewReload = () async throws -> (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 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 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 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 let refreshButton = UIButton(type: .system)
private var tabHeightConstraint: Constraint?
/// 创建全屏预览页。
init(
projects: [TravelAlbumPreviewProject],
totalCount: Int,
startProjectIndex: Int,
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
) {
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
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"
refreshButton.configuration = makeActionButton(
title: "刷新",
systemName: "arrow.clockwise",
color: .white
).configuration
refreshButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
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, 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 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 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.showPreviewToast("AI修图任务已提交")
self.reloadProjects(showSuccessToast: false, forceRefreshImage: false)
}
)
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(showSuccessToast: true, forceRefreshImage: true)
}
private func reloadProjects(showSuccessToast: Bool, forceRefreshImage: Bool) {
guard !isRefreshingProject else { return }
guard let reload else {
if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") }
return
}
isRefreshingProject = true
updateRefreshButton()
let currentProjectId = currentProject?.id
let currentProjectIndex = currentNode?.projectIndex ?? 0
let kind = currentAsset?.kind ?? .original
Task { [weak self] in
guard let self else { return }
do {
let result = try await reload()
self.applySuccessfulReload(
projects: result.projects,
totalCount: result.totalCount,
currentProjectId: currentProjectId,
fallbackProjectIndex: currentProjectIndex,
kind: kind,
showSuccessToast: showSuccessToast,
forceRefreshImage: forceRefreshImage
)
} catch is CancellationError {
self.isRefreshingProject = false
self.updateRefreshButton()
} catch {
self.isRefreshingProject = false
self.updateRefreshButton()
if showSuccessToast {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
self.showPreviewToast(message.isEmpty ? "刷新失败" : message)
}
}
}
}
private func applySuccessfulReload(
projects refreshedProjects: [TravelAlbumPreviewProject],
totalCount: Int,
currentProjectId: Int?,
fallbackProjectIndex: Int,
kind: TravelAlbumPreviewAssetKind,
showSuccessToast: Bool,
forceRefreshImage: Bool
) {
let incoming = Self.deduplicated(refreshedProjects)
guard !incoming.isEmpty else {
isRefreshingProject = false
updateRefreshButton()
if showSuccessToast { dismiss(animated: true) }
return
}
projects = incoming
self.totalCount = max(totalCount, incoming.count)
let targetProjectIndex = currentProjectId.flatMap { id in
incoming.firstIndex { $0.id == id }
} ?? min(max(0, fallbackProjectIndex), incoming.count - 1)
let resolvedKind = incoming[targetProjectIndex].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()
if showSuccessToast { showPreviewToast("刷新成功") }
}
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)
}
}