From 99fbad3e97068c936d209c4c9ba81dd27312f787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 10 Aug 2026 10:07:19 +0800 Subject: [PATCH] feat: add iOS travel album photo preview --- .../Models/TravelAlbumPreviewModels.swift | 174 ++++ .../TravelAlbumDetailViewModel.swift | 5 + .../TravelAlbumDetailViewController.swift | 34 +- ...ravelAlbumPhotoPreviewViewController.swift | 768 ++++++++++++++++++ suixinkanTests/TravelAlbumModelsTests.swift | 122 +++ 5 files changed, 1101 insertions(+), 2 deletions(-) create mode 100644 suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift new file mode 100644 index 0000000..50cad9e --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift @@ -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() + 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("关联图片刷新接口待接入") + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift index 1a6f84a..bbacf62 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -137,6 +137,11 @@ final class TravelAlbumDetailViewModel { TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials) } + /// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。 + var currentPhotoCount: Int { + selectedTab == .all ? allPhotoCount : purchasedPhotoCount + } + /// 切换素材 tab。 func selectTab(_ tab: Tab, api: any TravelAlbumServing) async { guard selectedTab != tab else { return } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 7bcff0c..c7211ea 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -11,6 +11,7 @@ import UIKit final class TravelAlbumDetailViewController: BaseViewController { private let viewModel: TravelAlbumDetailViewModel private let api: any TravelAlbumServing + private let previewConfiguration: TravelAlbumPreviewConfiguration private let contentView = UIView() private let infoCard = TravelAlbumInfoCard() @@ -27,9 +28,14 @@ final class TravelAlbumDetailViewController: BaseViewController { private let deleteSelectedButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system) - init(albumId: Int, api: (any TravelAlbumServing)? = nil) { + init( + albumId: Int, + api: (any TravelAlbumServing)? = nil, + previewConfiguration: TravelAlbumPreviewConfiguration = .init() + ) { viewModel = TravelAlbumDetailViewModel(albumId: albumId) self.api = api ?? NetworkServices.shared.travelAlbumAPI + self.previewConfiguration = previewConfiguration super.init(nibName: nil, bundle: nil) } @@ -413,12 +419,36 @@ final class TravelAlbumDetailViewController: BaseViewController { ) navigationController?.pushViewController(controller, animated: true) } + + private func presentPreview(startingWith material: TravelAlbumMaterial) { + guard let startIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) else { return } + let previewViewModel = viewModel + let previewAPI = api + let controller = TravelAlbumPhotoPreviewViewController( + projects: viewModel.materials.map(TravelAlbumPreviewProject.init(material:)), + totalCount: viewModel.currentPhotoCount, + startProjectIndex: startIndex, + configuration: previewConfiguration, + loadMore: { + await previewViewModel.loadMaterials(reset: false, api: previewAPI) + return ( + previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)), + previewViewModel.currentPhotoCount + ) + } + ) + present(controller, animated: true) + } } extension TravelAlbumDetailViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let material = dataSource.itemIdentifier(for: indexPath) else { return } - viewModel.toggleMaterialSelection(material) + if viewModel.isSelectionMode { + viewModel.toggleMaterialSelection(material) + } else { + presentPreview(startingWith: material) + } } func scrollViewDidScroll(_ scrollView: UIScrollView) { diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift new file mode 100644 index 0000000..563a741 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -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() + 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 + ) { + 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) + } +} diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift index 5f6f631..f25a95a 100644 --- a/suixinkanTests/TravelAlbumModelsTests.swift +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -8,6 +8,109 @@ import XCTest /// 旅拍相册模型与展示工具测试。 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 { let json = """ { @@ -68,6 +171,25 @@ final class TravelAlbumModelsTests: XCTestCase { 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 func testBuildTodayAlbumNameUsesExistingSameDayCount() { let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!