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