// // TravelAlbumPhotoPreviewViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。 typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int) /// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。 final class TravelAlbumPhotoPreviewViewController: UIViewController { private var projects: [TravelAlbumPreviewProject] private var totalCount: Int private let configuration: TravelAlbumPreviewConfiguration private let actionHandler: any TravelAlbumPreviewActionHandling private let loadMore: TravelAlbumPreviewLoadMore? private let onProjectDeleted: ((Int) -> Void)? private var nodes: [TravelAlbumPreviewNode] = [] private var currentNodeIndex = 0 private var dragStartIndex = 0 private var selectedKind: TravelAlbumPreviewAssetKind = .original private var chromeVisible = true private var isLoadingMore = false private var isDeletingProject = false private var didApplyInitialPosition = false private var lastCollectionSize: CGSize = .zero private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) private let topChrome = UIView() private let closeButton = UIButton(type: .system) private let titleLabel = UILabel() private let sizeLabel = UILabel() private let counterLabel = UILabel() private let bottomChrome = UIView() private let divider = UIView() private let tabStack = UIStackView() private let actionStack = UIStackView() private let deleteButton = UIButton(type: .system) private var tabHeightConstraint: Constraint? /// 创建全屏预览页。 init( projects: [TravelAlbumPreviewProject], totalCount: Int, startProjectIndex: Int, configuration: TravelAlbumPreviewConfiguration = .init(), actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(), loadMore: TravelAlbumPreviewLoadMore? = nil, onProjectDeleted: ((Int) -> Void)? = nil ) { self.projects = Self.deduplicated(projects) self.totalCount = max(totalCount, projects.count) self.configuration = configuration self.actionHandler = actionHandler self.loadMore = loadMore self.onProjectDeleted = onProjectDeleted super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var prefersStatusBarHidden: Bool { true } override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] } override func viewDidLoad() { super.viewDidLoad() setupUI() updateForCurrentNode() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let collectionSize = collectionView.bounds.size if collectionSize != lastCollectionSize { lastCollectionSize = collectionSize collectionView.collectionViewLayout.invalidateLayout() if didApplyInitialPosition { setPage(currentNodeIndex, animated: false) } } guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return } didApplyInitialPosition = true setPage(currentNodeIndex, animated: false) } private func setupUI() { view.backgroundColor = .black collectionView.backgroundColor = .black collectionView.isPagingEnabled = true collectionView.alwaysBounceHorizontal = nodes.count > 1 collectionView.showsHorizontalScrollIndicator = false collectionView.delegate = self collectionView.dataSource = self collectionView.register( TravelAlbumPreviewImageCell.self, forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier ) topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.72) bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.78) divider.backgroundColor = UIColor.white.withAlphaComponent(0.2) closeButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)), for: .normal) closeButton.tintColor = .white closeButton.accessibilityLabel = "关闭图片预览" closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) titleLabel.textColor = .white titleLabel.font = .systemFont(ofSize: 15, weight: .medium) titleLabel.textAlignment = .center titleLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.textColor = UIColor.white.withAlphaComponent(0.65) sizeLabel.font = .systemFont(ofSize: 11, weight: .regular) sizeLabel.textAlignment = .center counterLabel.textColor = .white counterLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) counterLabel.textAlignment = .right counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal) tabStack.axis = .horizontal tabStack.distribution = .fillEqually tabStack.alignment = .fill actionStack.axis = .horizontal actionStack.distribution = .fillEqually actionStack.alignment = .fill configureActions() let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel]) titleStack.axis = .vertical titleStack.spacing = 3 titleStack.alignment = .fill view.addSubview(collectionView) view.addSubview(topChrome) topChrome.addSubview(closeButton) topChrome.addSubview(titleStack) topChrome.addSubview(counterLabel) view.addSubview(bottomChrome) bottomChrome.addSubview(divider) bottomChrome.addSubview(tabStack) bottomChrome.addSubview(actionStack) collectionView.snp.makeConstraints { $0.edges.equalToSuperview() } topChrome.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(64) } closeButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(8) make.bottom.equalToSuperview().offset(-10) make.size.equalTo(44) } counterLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.centerY.equalTo(closeButton) make.width.greaterThanOrEqualTo(52) } titleStack.snp.makeConstraints { make in make.centerY.equalTo(closeButton) make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8) make.trailing.lessThanOrEqualTo(counterLabel.snp.leading).offset(-8) make.centerX.equalToSuperview() make.width.lessThanOrEqualTo(220) } bottomChrome.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } divider.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } tabStack.snp.makeConstraints { make in make.top.equalTo(divider.snp.bottom) make.leading.trailing.equalToSuperview().inset(12) tabHeightConstraint = make.height.equalTo(0).constraint } actionStack.snp.makeConstraints { make in make.top.equalTo(tabStack.snp.bottom) make.leading.trailing.equalToSuperview().inset(8) make.height.equalTo(70) make.bottom.equalTo(view.safeAreaLayoutGuide) } } private func configureActions() { let aiButton = makeActionButton(title: "AI修图", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA)) let deleteConfiguration = makeActionButton( title: "删除", systemName: "trash", color: UIColor(hex: 0xF87171) ).configuration deleteButton.configuration = deleteConfiguration deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) deleteButton.accessibilityLabel = "删除" deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton" let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white) aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) [aiButton, deleteButton, refreshButton].forEach(actionStack.addArrangedSubview) } private func makeActionButton(title: String, systemName: String, color: UIColor) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.title = title configuration.image = UIImage(systemName: systemName) configuration.imagePlacement = .top configuration.imagePadding = 5 configuration.baseForegroundColor = color configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6) let button = UIButton(configuration: configuration) button.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) button.accessibilityLabel = title return button } private func makeLayout() -> UICollectionViewLayout { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 return layout } private var currentNode: TravelAlbumPreviewNode? { nodes.indices.contains(currentNodeIndex) ? nodes[currentNodeIndex] : nil } private var currentProject: TravelAlbumPreviewProject? { guard let node = currentNode, projects.indices.contains(node.projectIndex) else { return nil } return projects[node.projectIndex] } private var currentAsset: TravelAlbumPreviewAsset? { guard let node = currentNode, let project = currentProject else { return nil } let kind = configuration.swipeMode == .projectsOnly ? selectedKind : node.kind return project.asset(for: kind) ?? project.asset(for: .original) } private func updateForCurrentNode() { guard let node = currentNode else { return } let asset = currentAsset titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片" sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0) counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))" counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张" rebuildTabs() loadMoreIfNeeded(projectIndex: node.projectIndex) } private func rebuildTabs() { tabStack.arrangedSubviews.forEach { tabStack.removeArrangedSubview($0) $0.removeFromSuperview() } guard let project = currentProject, project.hasVariants else { tabStack.isHidden = true tabHeightConstraint?.update(offset: 0) return } tabStack.isHidden = false tabHeightConstraint?.update(offset: 44) let activeKind = currentAsset?.kind ?? .original for asset in project.orderedAssets { let button = UIButton(type: .system) button.tag = asset.kind.rawValue button.setTitle(asset.kind.title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 13, weight: asset.kind == activeKind ? .semibold : .medium) button.setTitleColor(asset.kind == activeKind ? .white : UIColor.white.withAlphaComponent(0.55), for: .normal) button.backgroundColor = asset.kind == activeKind ? UIColor.white.withAlphaComponent(0.12) : .clear button.layer.cornerRadius = 8 button.accessibilityLabel = asset.kind.title button.accessibilityTraits = asset.kind == activeKind ? [.button, .selected] : .button button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) tabStack.addArrangedSubview(button) } view.layoutIfNeeded() if !chromeVisible { bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height) } } @objc private func tabTapped(_ sender: UIButton) { guard let kind = TravelAlbumPreviewAssetKind(rawValue: sender.tag), let node = currentNode, currentProject?.asset(for: kind) != nil else { return } switch configuration.swipeMode { case .projectsOnly: selectedKind = kind collectionView.reloadItems(at: [IndexPath(item: currentNodeIndex, section: 0)]) updateForCurrentNode() case .includeVariants: guard let target = nodes.firstIndex(where: { $0.projectIndex == node.projectIndex && $0.kind == kind }) else { return } currentNodeIndex = target setPage(target, animated: true) updateForCurrentNode() } } private func didChangePage(to index: Int) { guard nodes.indices.contains(index) else { return } let oldNodeIndex = currentNodeIndex let oldProjectIndex = currentNode?.projectIndex guard oldNodeIndex != index else { updateForCurrentNode() return } currentNodeIndex = index if oldProjectIndex != currentNode?.projectIndex { selectedKind = .original resetProjectCellToOriginalIfNeeded(at: oldNodeIndex) } collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() } updateForCurrentNode() } private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) { guard configuration.swipeMode == .projectsOnly, nodes.indices.contains(nodeIndex), let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell else { return } let project = projects[nodes[nodeIndex].projectIndex] cell.apply(asset: project.asset(for: .original)) } private func setPage(_ index: Int, animated: Bool) { guard collectionView.bounds.width > 0, nodes.indices.contains(index) else { return } collectionView.setContentOffset(CGPoint(x: CGFloat(index) * collectionView.bounds.width, y: 0), animated: animated) } private func rebuildNodes(keepingProjectIndex: Int, kind: TravelAlbumPreviewAssetKind) { nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: configuration.swipeMode) currentNodeIndex = nodes.firstIndex { $0.projectIndex == keepingProjectIndex && (configuration.swipeMode == .projectsOnly || $0.kind == kind) } ?? nodes.firstIndex { $0.projectIndex == keepingProjectIndex } ?? 0 } private func loadMoreIfNeeded(projectIndex: Int) { guard projectIndex >= projects.count - 4, projects.count < totalCount, !isLoadingMore, let loadMore else { return } isLoadingMore = true let currentProjectId = currentProject?.id let kind = currentAsset?.kind ?? .original Task { [weak self] in let result = await loadMore() guard let self else { return } let incoming = Self.deduplicated(result.projects) guard incoming.count > self.projects.count else { self.totalCount = max(result.totalCount, incoming.count) self.isLoadingMore = false return } self.projects = incoming self.totalCount = max(result.totalCount, incoming.count) let projectIndex = currentProjectId.flatMap { id in incoming.firstIndex { $0.id == id } } ?? 0 self.rebuildNodes(keepingProjectIndex: projectIndex, kind: kind) self.collectionView.reloadData() self.collectionView.layoutIfNeeded() self.setPage(self.currentNodeIndex, animated: false) self.isLoadingMore = false self.updateForCurrentNode() } } private static func deduplicated(_ projects: [TravelAlbumPreviewProject]) -> [TravelAlbumPreviewProject] { var seen = Set() return projects.filter { seen.insert($0.id).inserted } } private func toggleChrome() { chromeVisible.toggle() let reduceMotion = UIAccessibility.isReduceMotionEnabled let animations = { self.topChrome.alpha = self.chromeVisible ? 1 : 0 self.bottomChrome.alpha = self.chromeVisible ? 1 : 0 self.topChrome.transform = self.chromeVisible ? .identity : CGAffineTransform(translationX: 0, y: -self.topChrome.bounds.height) self.bottomChrome.transform = self.chromeVisible ? .identity : CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height) } UIView.animate( withDuration: reduceMotion ? 0.12 : 0.22, delay: 0, options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState], animations: animations ) } private func performAction(_ operation: @escaping () async -> TravelAlbumPreviewActionResult) { Task { [weak self] in guard let self else { return } let result = await operation() switch result { case .success(let message): if let message, !message.isEmpty { self.showPreviewToast(message) } case .failure(let message), .unavailable(let message): self.showPreviewToast(message) } } } private func showPreviewToast(_ message: String) { let label = TravelAlbumPreviewToastLabel() label.text = message label.textColor = .white label.font = .systemFont(ofSize: 14, weight: .medium) label.textAlignment = .center label.backgroundColor = UIColor(white: 0.12, alpha: 0.94) label.layer.cornerRadius = 10 label.clipsToBounds = true label.alpha = 0 view.addSubview(label) label.snp.makeConstraints { make in make.centerX.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-110) make.width.lessThanOrEqualToSuperview().inset(32) } UIView.animate(withDuration: 0.18, animations: { label.alpha = 1 }) { _ in UIView.animate(withDuration: 0.2, delay: 1.5, options: .curveEaseIn, animations: { label.alpha = 0 }) { _ in label.removeFromSuperview() } } } @objc private func closeTapped() { dismiss(animated: true) } @objc private func aiTapped() { guard let id = currentProject?.originalMaterialId else { return } performAction { [actionHandler] in await actionHandler.requestAIRetouch(originalMaterialId: id) } } @objc private func deleteTapped() { guard currentProject != nil, !isDeletingProject else { return } let alert = UIAlertController( title: "删除整个项目", message: "将同时删除原图及其全部关联图片,是否继续?", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in self?.deleteCurrentProjectAfterConfirmation() }) present(alert, animated: true) } /// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。 func deleteCurrentProjectAfterConfirmation() { guard !isDeletingProject, let project = currentProject, let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id }) else { return } isDeletingProject = true updateDeleteButton() Task { [weak self, actionHandler] in let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId) guard let self else { return } switch result { case .success(let message): self.applySuccessfulDeletion( project: project, deletedProjectIndex: deletedProjectIndex, message: message ) case .failure(let message), .unavailable(let message): self.isDeletingProject = false self.updateDeleteButton() self.showPreviewToast(message) } } } private func applySuccessfulDeletion( project: TravelAlbumPreviewProject, deletedProjectIndex: Int, message: String? ) { guard projects.indices.contains(deletedProjectIndex), projects[deletedProjectIndex].id == project.id else { isDeletingProject = false updateDeleteButton() return } projects.remove(at: deletedProjectIndex) totalCount = max(0, totalCount - 1) onProjectDeleted?(project.originalMaterialId) guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion( deletedProjectIndex: deletedProjectIndex, remainingProjectCount: projects.count ) else { dismiss(animated: true) return } selectedKind = .original rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original) collectionView.reloadData() collectionView.layoutIfNeeded() setPage(currentNodeIndex, animated: false) isDeletingProject = false updateDeleteButton() updateForCurrentNode() if let message, !message.isEmpty { showPreviewToast(message) } } private func updateDeleteButton() { deleteButton.isEnabled = !isDeletingProject deleteButton.alpha = isDeletingProject ? 0.55 : 1 deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil var configuration = deleteButton.configuration configuration?.showsActivityIndicator = isDeletingProject configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash") configuration?.title = isDeletingProject ? "删除中" : "删除" deleteButton.configuration = configuration } @objc private func refreshTapped() { guard let id = currentProject?.originalMaterialId else { return } performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) } } } extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { nodes.count } func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier, for: indexPath ) as! TravelAlbumPreviewImageCell let node = nodes[indexPath.item] let project = projects[node.projectIndex] let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex ? selectedKind : node.kind cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original)) cell.onSingleTap = { [weak self] in self?.toggleChrome() } cell.onZoomChanged = { [weak self] zoomed in self?.collectionView.isScrollEnabled = !zoomed } return cell } func collectionView( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { collectionView.bounds.size } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { dragStartIndex = currentNodeIndex } func scrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer ) { guard configuration.swipeMode == .includeVariants, scrollView.bounds.width > 0 else { return } let proposed = Int(round(targetContentOffset.pointee.x / scrollView.bounds.width)) guard proposed < dragStartIndex else { return } let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: dragStartIndex) targetContentOffset.pointee.x = CGFloat(target) * scrollView.bounds.width } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { updatePageFromOffset() } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { updatePageFromOffset() } private func updatePageFromOffset() { guard collectionView.bounds.width > 0 else { return } let index = Int(round(collectionView.contentOffset.x / collectionView.bounds.width)) didChangePage(to: min(max(0, index), max(0, nodes.count - 1))) } } /// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。 private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate { static let reuseIdentifier = "TravelAlbumPreviewImageCell" var onSingleTap: (() -> Void)? var onZoomChanged: ((Bool) -> Void)? private let scrollView = UIScrollView() private let imageView = UIImageView() private let retryButton = UIButton(type: .system) private var asset: TravelAlbumPreviewAsset? override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() imageView.kf.cancelDownloadTask() imageView.image = nil asset = nil retryButton.isHidden = true resetZoom() } func apply(asset newAsset: TravelAlbumPreviewAsset?) { asset = newAsset resetZoom() loadImage() accessibilityLabel = newAsset.map { "\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)" } } func resetZoom() { scrollView.setZoomScale(1, animated: false) scrollView.contentOffset = .zero scrollView.panGestureRecognizer.isEnabled = false onZoomChanged?(false) } func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } func scrollViewDidZoom(_ scrollView: UIScrollView) { centerImage() let isZoomed = scrollView.zoomScale > 1.01 if scrollView.panGestureRecognizer.isEnabled != isZoomed { scrollView.panGestureRecognizer.isEnabled = isZoomed } onZoomChanged?(isZoomed) } private func setupUI() { backgroundColor = .black scrollView.backgroundColor = .black scrollView.delegate = self scrollView.minimumZoomScale = 1 scrollView.maximumZoomScale = 4 scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false scrollView.contentInsetAdjustmentBehavior = .never scrollView.panGestureRecognizer.isEnabled = false imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .black imageView.kf.indicatorType = .activity imageView.accessibilityIdentifier = "travelAlbum.previewImageView" retryButton.setTitle("图片加载失败,点击重试", for: .normal) retryButton.setTitleColor(UIColor.white.withAlphaComponent(0.82), for: .normal) retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) retryButton.isHidden = true retryButton.accessibilityLabel = "图片加载失败,重新加载" retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) contentView.addSubview(scrollView) scrollView.addSubview(imageView) contentView.addSubview(retryButton) scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } imageView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) make.width.equalTo(scrollView.frameLayoutGuide) make.height.equalTo(scrollView.frameLayoutGuide) } retryButton.snp.makeConstraints { $0.center.equalToSuperview() } let singleTap = UITapGestureRecognizer(target: self, action: #selector(singleTapped)) let doubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:))) doubleTap.numberOfTapsRequired = 2 singleTap.require(toFail: doubleTap) scrollView.addGestureRecognizer(singleTap) scrollView.addGestureRecognizer(doubleTap) } private func loadImage() { retryButton.isHidden = true let text = asset?.displayURL guard let text, let url = URL(string: text), !text.isEmpty else { imageView.image = nil retryButton.isHidden = false return } imageView.kf.setImage(with: url) { [weak self] result in guard let self, self.asset?.displayURL == text else { return } if case .failure = result, self.imageView.image == nil { self.retryButton.isHidden = false } } } private func centerImage() { let horizontal = max(0, (scrollView.bounds.width - imageView.frame.width) / 2) let vertical = max(0, (scrollView.bounds.height - imageView.frame.height) / 2) scrollView.contentInset = UIEdgeInsets(top: vertical, left: horizontal, bottom: vertical, right: horizontal) } @objc private func singleTapped() { onSingleTap?() } @objc private func doubleTapped(_ gesture: UITapGestureRecognizer) { if scrollView.zoomScale > 1.01 { scrollView.setZoomScale(1, animated: true) return } let point = gesture.location(in: imageView) let width = scrollView.bounds.width / 2 let height = scrollView.bounds.height / 2 scrollView.zoom(to: CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height), animated: true) } @objc private func retryTapped() { loadImage() } } /// 带内边距的轻量 Toast 标签。 private final class TravelAlbumPreviewToastLabel: UILabel { private let insets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14) override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } }