// // TravelAlbumPhotoPreviewViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。 typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int) /// 相册项目定向刷新回调,根据原素材 ID 返回该项目的最新数据。 typealias TravelAlbumPreviewReload = (_ materialId: Int) async throws -> TravelAlbumPreviewProject /// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。 final class TravelAlbumPhotoPreviewViewController: UIViewController { private var projects: [TravelAlbumPreviewProject] private var totalCount: Int private let configuration: TravelAlbumPreviewConfiguration private let actionHandler: any TravelAlbumPreviewActionHandling private let albumId: Int private let scenicIdProvider: () -> Int private let aiRetouchAPI: (any TravelAlbumServing)? private let loadMore: TravelAlbumPreviewLoadMore? private let reload: TravelAlbumPreviewReload? private let onProjectDeleted: ((Int) -> Void)? private var nodes: [TravelAlbumPreviewNode] = [] private var currentNodeIndex = 0 private var dragStartIndex = 0 private var selectedKind: TravelAlbumPreviewAssetKind = .original private var chromeVisible = true private var isLoadingMore = false private var isRefreshingProject = false private var isDeletingProject = false private var didApplyInitialPosition = false private var lastCollectionSize: CGSize = .zero private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) private let topChrome = UIView() private let closeButton = UIButton(type: .system) private let titleLabel = UILabel() private let sizeLabel = UILabel() private let counterContainer = UIView() private let counterLabel = UILabel() private let bottomChrome = UIView() private let divider = UIView() private let variantControls = UIView() private let variantSegmentedControl = UISegmentedControl() private let highResolutionButton = UIButton(type: .system) private let actionStack = UIStackView() private let deleteButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system) private var variantControlsHeightConstraint: Constraint? private var variantSegmentedControlWidthConstraint: Constraint? private var didConfigureVariantControls = false private var showsVariantControls = false private var variantControlsAnimationGeneration = 0 /// 创建全屏预览页。 init( projects: [TravelAlbumPreviewProject], totalCount: Int, startProjectIndex: Int, configuration: TravelAlbumPreviewConfiguration = .init(), actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(), albumId: Int = 0, scenicIdProvider: @escaping () -> Int = { 0 }, aiRetouchAPI: (any TravelAlbumServing)? = nil, loadMore: TravelAlbumPreviewLoadMore? = nil, reload: TravelAlbumPreviewReload? = nil, onProjectDeleted: ((Int) -> Void)? = nil ) { self.projects = Self.deduplicated(projects) self.totalCount = max(totalCount, projects.count) self.configuration = configuration self.actionHandler = actionHandler self.albumId = albumId self.scenicIdProvider = scenicIdProvider self.aiRetouchAPI = aiRetouchAPI self.loadMore = loadMore self.reload = reload self.onProjectDeleted = onProjectDeleted super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var prefersStatusBarHidden: Bool { false } override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] } override func viewDidLoad() { super.viewDidLoad() setupUI() updateForCurrentNode() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let collectionSize = collectionView.bounds.size if collectionSize != lastCollectionSize { lastCollectionSize = collectionSize collectionView.collectionViewLayout.invalidateLayout() if didApplyInitialPosition { setPage(currentNodeIndex, animated: false) } } guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return } didApplyInitialPosition = true setPage(currentNodeIndex, animated: false) } private func setupUI() { view.backgroundColor = .black collectionView.backgroundColor = .black collectionView.isPagingEnabled = true collectionView.alwaysBounceHorizontal = nodes.count > 1 collectionView.showsHorizontalScrollIndicator = false collectionView.contentInsetAdjustmentBehavior = .never collectionView.accessibilityIdentifier = "travelAlbum.previewCollectionView" collectionView.delegate = self collectionView.dataSource = self collectionView.register( TravelAlbumPreviewImageCell.self, forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier ) topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.82) topChrome.accessibilityIdentifier = "travelAlbum.previewTopChrome" bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.82) bottomChrome.accessibilityIdentifier = "travelAlbum.previewBottomChrome" divider.backgroundColor = UIColor.white.withAlphaComponent(0.12) var backConfiguration = UIButton.Configuration.plain() backConfiguration.image = UIImage(systemName: "chevron.backward") backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( pointSize: 21, weight: .semibold ) backConfiguration.baseForegroundColor = .white backConfiguration.contentInsets = .zero closeButton.configuration = backConfiguration closeButton.accessibilityLabel = "关闭图片预览" closeButton.accessibilityIdentifier = "travelAlbum.previewBackButton" closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) titleLabel.textColor = .white titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textAlignment = .center titleLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52) sizeLabel.font = .systemFont(ofSize: 13, weight: .regular) sizeLabel.textAlignment = .center counterLabel.textColor = UIColor.white.withAlphaComponent(0.82) counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium) counterLabel.textAlignment = .center counterLabel.numberOfLines = 1 counterLabel.accessibilityIdentifier = "travelAlbum.previewCounterLabel" counterLabel.setContentHuggingPriority(.required, for: .horizontal) counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal) counterContainer.backgroundColor = UIColor(white: 0.11, alpha: 1) counterContainer.layer.cornerRadius = 17 counterContainer.clipsToBounds = true variantSegmentedControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2) variantSegmentedControl.setTitleTextAttributes( [.foregroundColor: UIColor.white.withAlphaComponent(0.7)], for: .normal ) variantSegmentedControl.setTitleTextAttributes( [.foregroundColor: UIColor.white], for: .selected ) variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl" variantSegmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged) actionStack.axis = .horizontal actionStack.distribution = .fillEqually actionStack.alignment = .fill actionStack.spacing = 10 actionStack.isLayoutMarginsRelativeArrangement = true actionStack.directionalLayoutMargins = NSDirectionalEdgeInsets( top: 9, leading: 0, bottom: 9, trailing: 0 ) actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack" configureHighResolutionButton() 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(counterContainer) counterContainer.addSubview(counterLabel) view.addSubview(bottomChrome) bottomChrome.addSubview(divider) bottomChrome.addSubview(variantControls) variantControls.addSubview(variantSegmentedControl) variantControls.addSubview(highResolutionButton) bottomChrome.addSubview(actionStack) topChrome.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(72) } closeButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.bottom.equalToSuperview().offset(-14) make.size.equalTo(44) } counterContainer.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.centerY.equalTo(closeButton) make.width.greaterThanOrEqualTo(48) make.height.equalTo(34) } counterLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() } titleStack.snp.makeConstraints { make in make.centerY.equalTo(closeButton) make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8) make.trailing.lessThanOrEqualTo(counterContainer.snp.leading).offset(-8) make.centerX.equalToSuperview() make.width.lessThanOrEqualTo(220) } bottomChrome.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } divider.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } variantControls.snp.makeConstraints { make in make.top.equalTo(divider.snp.bottom) make.leading.trailing.equalToSuperview() variantControlsHeightConstraint = make.height.equalTo(56).constraint } variantSegmentedControl.snp.makeConstraints { make in make.leading.equalToSuperview().offset(20) make.centerY.equalToSuperview() variantSegmentedControlWidthConstraint = make.width.equalTo(124).constraint make.height.equalTo(32) make.trailing.lessThanOrEqualTo(highResolutionButton.snp.leading).offset(-12) } highResolutionButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(18) make.centerY.equalToSuperview() make.width.equalTo(116) make.height.equalTo(40) } actionStack.snp.makeConstraints { make in make.top.equalTo(variantControls.snp.bottom) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(70) make.bottom.equalTo(view.safeAreaLayoutGuide) } } private func configureHighResolutionButton() { var configuration = UIButton.Configuration.filled() configuration.title = "查看高清图" configuration.baseForegroundColor = .white configuration.baseBackgroundColor = UIColor(white: 0.18, alpha: 1) configuration.background.cornerRadius = 20 configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in var attributes = attributes attributes.font = .systemFont(ofSize: 15, weight: .medium) return attributes } highResolutionButton.configuration = configuration highResolutionButton.accessibilityLabel = "查看高清图" highResolutionButton.accessibilityIdentifier = "travelAlbum.previewHighResolutionButton" highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside) } private func configureActions() { let aiButton = makeActionButton( title: "AI修图", systemName: "wand.and.stars", foregroundColor: .white, backgroundColor: UIColor(hex: 0x1677FF), strokeColor: UIColor(hex: 0x4096FF) ) aiButton.accessibilityIdentifier = "travelAlbum.previewAIRetouchButton" let deleteConfiguration = makeActionButton( title: "删除", systemName: "trash", foregroundColor: UIColor(hex: 0xFF6B6B), backgroundColor: UIColor(hex: 0x211719), strokeColor: UIColor(hex: 0x4A292D) ).configuration deleteButton.configuration = deleteConfiguration deleteButton.accessibilityLabel = "删除" deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton" refreshButton.configuration = makeActionButton( title: "刷新", systemName: "arrow.clockwise", foregroundColor: UIColor.white.withAlphaComponent(0.92), backgroundColor: UIColor(hex: 0x1C1C1E), strokeColor: UIColor.white.withAlphaComponent(0.1) ).configuration refreshButton.accessibilityLabel = "刷新" refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton" aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) [aiButton, deleteButton, refreshButton].forEach(actionStack.addArrangedSubview) } private func makeActionButton( title: String, systemName: String, foregroundColor: UIColor, backgroundColor: UIColor, strokeColor: UIColor ) -> UIButton { var configuration = UIButton.Configuration.filled() configuration.title = title configuration.image = UIImage(systemName: systemName) configuration.imagePlacement = .leading configuration.imagePadding = 7 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( pointSize: 18, weight: .semibold ) configuration.baseForegroundColor = foregroundColor configuration.baseBackgroundColor = backgroundColor configuration.background.cornerRadius = 14 configuration.background.strokeColor = strokeColor configuration.background.strokeWidth = 1 configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in var attributes = attributes attributes.font = .systemFont(ofSize: 14, weight: .semibold) return attributes } let button = UIButton(configuration: configuration) button.accessibilityLabel = title return button } private func makeLayout() -> UICollectionViewLayout { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 return layout } private var currentNode: TravelAlbumPreviewNode? { nodes.indices.contains(currentNodeIndex) ? nodes[currentNodeIndex] : nil } private var currentProject: TravelAlbumPreviewProject? { guard let node = currentNode, projects.indices.contains(node.projectIndex) else { return nil } return projects[node.projectIndex] } private var currentAsset: TravelAlbumPreviewAsset? { guard let node = currentNode, let project = currentProject else { return nil } let kind = configuration.swipeMode == .projectsOnly ? selectedKind : node.kind return project.asset(for: kind) ?? project.asset(for: .original) } private func updateForCurrentNode() { guard let node = currentNode else { return } let asset = currentAsset titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片" sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0) counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))" counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张" highResolutionButton.isEnabled = asset != nil highResolutionButton.alpha = asset == nil ? 0.45 : 1 rebuildTabs() loadMoreIfNeeded(projectIndex: node.projectIndex) } private func rebuildTabs() { while variantSegmentedControl.numberOfSegments > 0 { variantSegmentedControl.removeSegment(at: 0, animated: false) } guard let project = currentProject, project.hasVariants else { setVariantControlsVisible(false) return } let activeKind = currentAsset?.kind ?? .original for (index, asset) in project.orderedAssets.enumerated() { variantSegmentedControl.insertSegment(withTitle: asset.kind.title, at: index, animated: false) } let controlWidth: CGFloat switch project.orderedAssets.count { case 4: controlWidth = 220 case 3: controlWidth = 180 default: controlWidth = 124 } variantSegmentedControlWidthConstraint?.update(offset: controlWidth) variantSegmentedControl.selectedSegmentIndex = project.orderedAssets.firstIndex { $0.kind == activeKind } ?? 0 setVariantControlsVisible(true) } private func setVariantControlsVisible(_ visible: Bool) { let visibilityChanged = didConfigureVariantControls && showsVariantControls != visible didConfigureVariantControls = true showsVariantControls = visible variantControlsAnimationGeneration += 1 let animationGeneration = variantControlsAnimationGeneration let shouldAnimate = visibilityChanged && chromeVisible && view.window != nil && UIView.areAnimationsEnabled && !UIAccessibility.isReduceMotionEnabled view.layoutIfNeeded() variantControls.isHidden = false variantSegmentedControl.isHidden = false highResolutionButton.isHidden = false variantControlsHeightConstraint?.update(offset: visible ? 56 : 0) guard shouldAnimate else { variantControls.alpha = 1 variantControls.transform = .identity view.layoutIfNeeded() variantControls.isHidden = !visible variantSegmentedControl.isHidden = !visible highResolutionButton.isHidden = !visible updateHiddenBottomChromePositionIfNeeded() return } if visible { variantControls.alpha = 0 variantControls.transform = CGAffineTransform(translationX: 0, y: 8) } UIView.animate( withDuration: 0.24, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction], animations: { self.variantControls.alpha = visible ? 1 : 0 self.variantControls.transform = visible ? .identity : CGAffineTransform(translationX: 0, y: 8) self.view.layoutIfNeeded() }, completion: { [weak self] finished in guard let self, finished, self.variantControlsAnimationGeneration == animationGeneration else { return } self.variantControls.isHidden = !visible self.variantSegmentedControl.isHidden = !visible self.highResolutionButton.isHidden = !visible self.variantControls.alpha = 1 self.variantControls.transform = .identity self.updateHiddenBottomChromePositionIfNeeded() } ) } private func updateHiddenBottomChromePositionIfNeeded() { guard !chromeVisible else { return } bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height) } @objc private func segmentChanged(_ sender: UISegmentedControl) { guard let project = currentProject, project.orderedAssets.indices.contains(sender.selectedSegmentIndex) else { return } let kind = project.orderedAssets[sender.selectedSegmentIndex].kind guard let node = currentNode, currentProject?.asset(for: kind) != nil else { return } switch configuration.swipeMode { case .projectsOnly: selectedKind = kind collectionView.reloadItems(at: [IndexPath(item: currentNodeIndex, section: 0)]) updateForCurrentNode() case .includeVariants: guard let target = nodes.firstIndex(where: { $0.projectIndex == node.projectIndex && $0.kind == kind }) else { return } currentNodeIndex = target setPage(target, animated: true) updateForCurrentNode() } } private func didChangePage(to index: Int) { guard nodes.indices.contains(index) else { return } let oldNodeIndex = currentNodeIndex let oldProjectIndex = currentNode?.projectIndex guard oldNodeIndex != index else { updateForCurrentNode() return } currentNodeIndex = index if oldProjectIndex != currentNode?.projectIndex { selectedKind = .original resetProjectCellToOriginalIfNeeded(at: oldNodeIndex) } collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() } updateForCurrentNode() } private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) { guard configuration.swipeMode == .projectsOnly, nodes.indices.contains(nodeIndex), let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell else { return } let project = projects[nodes[nodeIndex].projectIndex] cell.apply(asset: project.asset(for: .original)) } private func setPage(_ index: Int, animated: Bool) { guard collectionView.bounds.width > 0, nodes.indices.contains(index) else { return } collectionView.setContentOffset(CGPoint(x: CGFloat(index) * collectionView.bounds.width, y: 0), animated: animated) } private func rebuildNodes(keepingProjectIndex: Int, kind: TravelAlbumPreviewAssetKind) { nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: configuration.swipeMode) currentNodeIndex = nodes.firstIndex { $0.projectIndex == keepingProjectIndex && (configuration.swipeMode == .projectsOnly || $0.kind == kind) } ?? nodes.firstIndex { $0.projectIndex == keepingProjectIndex } ?? 0 } private func loadMoreIfNeeded(projectIndex: Int) { guard projectIndex >= projects.count - 4, projects.count < totalCount, !isLoadingMore, let loadMore else { return } isLoadingMore = true let currentProjectId = currentProject?.id let kind = currentAsset?.kind ?? .original Task { [weak self] in let result = await loadMore() guard let self else { return } let incoming = Self.deduplicated(result.projects) guard incoming.count > self.projects.count else { self.totalCount = max(result.totalCount, incoming.count) self.isLoadingMore = false return } self.projects = incoming self.totalCount = max(result.totalCount, incoming.count) let projectIndex = currentProjectId.flatMap { id in incoming.firstIndex { $0.id == id } } ?? 0 self.rebuildNodes(keepingProjectIndex: projectIndex, kind: kind) self.collectionView.reloadData() self.collectionView.layoutIfNeeded() self.setPage(self.currentNodeIndex, animated: false) self.isLoadingMore = false self.updateForCurrentNode() } } private static func deduplicated(_ projects: [TravelAlbumPreviewProject]) -> [TravelAlbumPreviewProject] { var seen = Set() return projects.filter { seen.insert($0.id).inserted } } private func toggleChrome() { chromeVisible.toggle() let reduceMotion = UIAccessibility.isReduceMotionEnabled let animations = { self.topChrome.alpha = self.chromeVisible ? 1 : 0 self.bottomChrome.alpha = self.chromeVisible ? 1 : 0 self.topChrome.transform = self.chromeVisible ? .identity : CGAffineTransform(translationX: 0, y: -self.topChrome.bounds.height) self.bottomChrome.transform = self.chromeVisible ? .identity : CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height) } UIView.animate( withDuration: reduceMotion ? 0.12 : 0.22, delay: 0, options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState], animations: animations ) } private func showPreviewToast(_ message: String) { let label = TravelAlbumPreviewToastLabel() label.text = message label.textColor = .white label.font = .systemFont(ofSize: 14, weight: .medium) label.textAlignment = .center label.backgroundColor = UIColor(white: 0.12, alpha: 0.94) label.layer.cornerRadius = 10 label.clipsToBounds = true label.alpha = 0 view.addSubview(label) label.snp.makeConstraints { make in make.centerX.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-110) make.width.lessThanOrEqualToSuperview().inset(32) } UIView.animate(withDuration: 0.18, animations: { label.alpha = 1 }) { _ in UIView.animate(withDuration: 0.2, delay: 1.5, options: .curveEaseIn, animations: { label.alpha = 0 }) { _ in label.removeFromSuperview() } } } @objc private func closeTapped() { dismiss(animated: true) } @objc private func highResolutionTapped() { guard chromeVisible else { return } toggleChrome() } @objc private func aiTapped() { guard presentedViewController == nil else { return } guard let project = currentProject else { return } guard let aiRetouchAPI else { showPreviewToast("AI修图功能暂不可用") return } let scenicId = scenicIdProvider() guard scenicId > 0 else { showPreviewToast("请先选择景区") return } let selectedKind = currentAsset?.kind ?? .original guard let workflow = project.aiRetouchWorkflow(albumId: albumId, selectedKind: selectedKind) else { showPreviewToast("当前图片不支持AI修图") return } let controller = TravelAlbumAIRetouchTemplateViewController( viewModel: TravelAlbumAIRetouchTemplateViewModel( scenicId: scenicId, workflow: workflow ), api: aiRetouchAPI, onSubmitted: { [weak self] in guard let self else { return } self.showPreviewToast("AI修图任务已提交") self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false) } ) present(controller, animated: true) } @objc private func deleteTapped() { guard currentProject != nil, !isDeletingProject else { return } let alert = UIAlertController( title: "删除整个项目", message: "将同时删除原图及其全部关联图片,是否继续?", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in self?.deleteCurrentProjectAfterConfirmation() }) present(alert, animated: true) } /// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。 func deleteCurrentProjectAfterConfirmation() { guard !isDeletingProject, let project = currentProject, let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id }) else { return } isDeletingProject = true updateDeleteButton() Task { [weak self, actionHandler] in let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId) guard let self else { return } switch result { case .success(let message): self.applySuccessfulDeletion( project: project, deletedProjectIndex: deletedProjectIndex, message: message ) case .failure(let message), .unavailable(let message): self.isDeletingProject = false self.updateDeleteButton() self.showPreviewToast(message) } } } private func applySuccessfulDeletion( project: TravelAlbumPreviewProject, deletedProjectIndex: Int, message: String? ) { guard projects.indices.contains(deletedProjectIndex), projects[deletedProjectIndex].id == project.id else { isDeletingProject = false updateDeleteButton() return } projects.remove(at: deletedProjectIndex) totalCount = max(0, totalCount - 1) onProjectDeleted?(project.originalMaterialId) guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion( deletedProjectIndex: deletedProjectIndex, remainingProjectCount: projects.count ) else { dismiss(animated: true) return } selectedKind = .original rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original) collectionView.reloadData() collectionView.layoutIfNeeded() setPage(currentNodeIndex, animated: false) isDeletingProject = false updateDeleteButton() updateForCurrentNode() if let message, !message.isEmpty { showPreviewToast(message) } } private func updateDeleteButton() { deleteButton.isEnabled = !isDeletingProject deleteButton.alpha = isDeletingProject ? 0.55 : 1 deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil var configuration = deleteButton.configuration configuration?.showsActivityIndicator = isDeletingProject configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash") configuration?.title = isDeletingProject ? "删除中" : "删除" deleteButton.configuration = configuration } @objc private func refreshTapped() { reloadProjects(showGlobalLoading: true, forceRefreshImage: true) } private func reloadProjects(showGlobalLoading: Bool, forceRefreshImage: Bool) { guard !isRefreshingProject else { return } guard let reload else { return } isRefreshingProject = true updateRefreshButton() if showGlobalLoading { GlobalLoadingManager.shared.show() } let currentProjectId = currentProject?.id let currentProjectIndex = currentNode?.projectIndex ?? 0 let kind = currentAsset?.kind ?? .original Task { [weak self] in defer { if showGlobalLoading { GlobalLoadingManager.shared.hide() } } guard let self else { return } do { guard let currentProjectId else { self.isRefreshingProject = false self.updateRefreshButton() return } let project = try await reload(currentProjectId) self.applySuccessfulReload( project: project, currentProjectId: currentProjectId, fallbackProjectIndex: currentProjectIndex, kind: kind, forceRefreshImage: forceRefreshImage ) } catch is CancellationError { self.isRefreshingProject = false self.updateRefreshButton() } catch { self.isRefreshingProject = false self.updateRefreshButton() } } } private func applySuccessfulReload( project refreshedProject: TravelAlbumPreviewProject, currentProjectId: Int, fallbackProjectIndex: Int, kind: TravelAlbumPreviewAssetKind, forceRefreshImage: Bool ) { guard !projects.isEmpty else { isRefreshingProject = false updateRefreshButton() dismiss(animated: true) return } let targetProjectIndex = projects.firstIndex { $0.id == currentProjectId } ?? min(max(0, fallbackProjectIndex), projects.count - 1) projects[targetProjectIndex] = refreshedProject let resolvedKind = refreshedProject.asset(for: kind) == nil ? .original : kind selectedKind = resolvedKind rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind) collectionView.reloadData() collectionView.layoutIfNeeded() setPage(currentNodeIndex, animated: false) updateForCurrentNode() if forceRefreshImage { forceRefreshCurrentImage() } isRefreshingProject = false updateRefreshButton() } private func forceRefreshCurrentImage() { let indexPath = IndexPath(item: currentNodeIndex, section: 0) guard let cell = collectionView.cellForItem(at: indexPath) as? TravelAlbumPreviewImageCell else { return } cell.apply(asset: currentAsset, forceRefresh: true) } private func updateRefreshButton() { refreshButton.isEnabled = !isRefreshingProject refreshButton.alpha = isRefreshingProject ? 0.55 : 1 refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil var configuration = refreshButton.configuration configuration?.showsActivityIndicator = isRefreshingProject configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise") configuration?.title = isRefreshingProject ? "刷新中" : "刷新" refreshButton.configuration = configuration } } extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { nodes.count } func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier, for: indexPath ) as! TravelAlbumPreviewImageCell let node = nodes[indexPath.item] let project = projects[node.projectIndex] let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex ? selectedKind : node.kind cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original)) cell.onSingleTap = { [weak self] in self?.toggleChrome() } cell.onZoomChanged = { [weak self] zoomed in self?.collectionView.isScrollEnabled = !zoomed } return cell } func collectionView( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { collectionView.bounds.size } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { dragStartIndex = currentNodeIndex } func scrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer ) { guard configuration.swipeMode == .includeVariants, scrollView.bounds.width > 0 else { return } let proposed = Int(round(targetContentOffset.pointee.x / scrollView.bounds.width)) guard proposed < dragStartIndex else { return } let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: dragStartIndex) targetContentOffset.pointee.x = CGFloat(target) * scrollView.bounds.width } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { updatePageFromOffset() } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { updatePageFromOffset() } private func updatePageFromOffset() { guard collectionView.bounds.width > 0 else { return } let index = Int(round(collectionView.contentOffset.x / collectionView.bounds.width)) didChangePage(to: min(max(0, index), max(0, nodes.count - 1))) } } /// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。 private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate { static let reuseIdentifier = "TravelAlbumPreviewImageCell" var onSingleTap: (() -> Void)? var onZoomChanged: ((Bool) -> Void)? private let scrollView = UIScrollView() private let imageView = UIImageView() private let retryButton = UIButton(type: .system) private var asset: TravelAlbumPreviewAsset? override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() imageView.kf.cancelDownloadTask() imageView.image = nil asset = nil retryButton.isHidden = true resetZoom() } func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) { asset = newAsset resetZoom() loadImage(forceRefresh: forceRefresh) accessibilityLabel = newAsset.map { "\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)" } } func resetZoom() { scrollView.setZoomScale(1, animated: false) scrollView.contentOffset = .zero scrollView.panGestureRecognizer.isEnabled = false onZoomChanged?(false) } func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } func scrollViewDidZoom(_ scrollView: UIScrollView) { centerImage() let isZoomed = scrollView.zoomScale > 1.01 if scrollView.panGestureRecognizer.isEnabled != isZoomed { scrollView.panGestureRecognizer.isEnabled = isZoomed } onZoomChanged?(isZoomed) } private func setupUI() { backgroundColor = .black scrollView.backgroundColor = .black scrollView.delegate = self scrollView.minimumZoomScale = 1 scrollView.maximumZoomScale = 4 scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false scrollView.contentInsetAdjustmentBehavior = .never scrollView.panGestureRecognizer.isEnabled = false imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .black imageView.kf.indicatorType = .activity imageView.accessibilityIdentifier = "travelAlbum.previewImageView" retryButton.setTitle("图片加载失败,点击重试", for: .normal) retryButton.setTitleColor(UIColor.white.withAlphaComponent(0.82), for: .normal) retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) retryButton.isHidden = true retryButton.accessibilityLabel = "图片加载失败,重新加载" retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) contentView.addSubview(scrollView) scrollView.addSubview(imageView) contentView.addSubview(retryButton) scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } imageView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) make.width.equalTo(scrollView.frameLayoutGuide) make.height.equalTo(scrollView.frameLayoutGuide) } retryButton.snp.makeConstraints { $0.center.equalToSuperview() } let singleTap = UITapGestureRecognizer(target: self, action: #selector(singleTapped)) let doubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:))) doubleTap.numberOfTapsRequired = 2 singleTap.require(toFail: doubleTap) scrollView.addGestureRecognizer(singleTap) scrollView.addGestureRecognizer(doubleTap) } private func loadImage(forceRefresh: Bool = false) { retryButton.isHidden = true let text = asset?.displayURL guard let text, let url = URL(string: text), !text.isEmpty else { imageView.image = nil retryButton.isHidden = false return } let options: KingfisherOptionsInfo? = forceRefresh ? [.forceRefresh] : nil imageView.kf.setImage(with: url, options: options) { [weak self] result in guard let self, self.asset?.displayURL == text else { return } if case .failure = result, self.imageView.image == nil { self.retryButton.isHidden = false } } } private func centerImage() { let horizontal = max(0, (scrollView.bounds.width - imageView.frame.width) / 2) let vertical = max(0, (scrollView.bounds.height - imageView.frame.height) / 2) scrollView.contentInset = UIEdgeInsets(top: vertical, left: horizontal, bottom: vertical, right: horizontal) } @objc private func singleTapped() { onSingleTap?() } @objc private func doubleTapped(_ gesture: UITapGestureRecognizer) { if scrollView.zoomScale > 1.01 { scrollView.setZoomScale(1, animated: true) return } let point = gesture.location(in: imageView) let width = scrollView.bounds.width / 2 let height = scrollView.bounds.height / 2 scrollView.zoom(to: CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height), animated: true) } @objc private func retryTapped() { loadImage(forceRefresh: true) } } /// 带内边距的轻量 Toast 标签。 private final class TravelAlbumPreviewToastLabel: UILabel { private let insets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14) override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } }