// // TravelAlbumPhotoPreviewViewController.swift // suixinkan // import CoreImage import Kingfisher import SnapKit import UIKit /// 相册管理中的单张照片预览页,提供 AI 修图、删除和刷新操作。 final class TravelAlbumPhotoPreviewViewController: UIViewController { /// 删除成功后通知相册管理页刷新当前素材列表。 var onDeleted: (() -> Void)? private var previewState: TravelAlbumPhotoPreviewState private let api: any TravelAlbumServing private let imageView = UIImageView() private let bottomBar = UIView() private let bottomContentStack = UIStackView() private let comparisonBar = UIStackView() private let versionControl = UISegmentedControl(items: ["原图", "精修后"]) private var visibleDisplayModes: [TravelAlbumAIEditResultState.DisplayMode] = [.original, .edited] private let highResolutionButton = UIButton(type: .system) private let actionStack = UIStackView() private let aiEditButton = TravelAlbumPreviewActionButton( title: "AI修图", icon: UIImage(named: "travel_album_preview_ai_edit") ?? UIImage(systemName: "wand.and.stars") ) private let aiEditResultStore = TravelAlbumAIEditResultStore.shared private let previewHeaderView = TravelAlbumPreviewHeaderView() private var originalImage: UIImage? private var originalImagesByMaterialID: [Int: UIImage] = [:] init(materials: [TravelAlbumMaterial], initialIndex: Int, api: any TravelAlbumServing) { precondition(!materials.isEmpty, "照片预览至少需要一张素材") previewState = TravelAlbumPhotoPreviewState(materials: materials, initialIndex: initialIndex) self.api = api super.init(nibName: nil, bundle: nil) // 与修图样式、封面模板预览保持同一种全屏容器,避免系统导航栏叠加。 modalPresentationStyle = .fullScreen modalTransitionStyle = .crossDissolve } /// 兼容单张照片入口,并统一复用连续预览实现。 convenience init(material: TravelAlbumMaterial, api: any TravelAlbumServing) { self.init(materials: [material], initialIndex: 0, api: api) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupNavigationBar() setupUI() NotificationCenter.default.addObserver( self, selector: #selector(aiEditResultDidChange(_:)), name: TravelAlbumAIEditResultStore.didChangeNotification, object: aiEditResultStore ) updateAIEditControls() loadImage() } override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } /// 使用沉浸式自定义头部展示返回入口、文件信息和当前照片位置。 private func setupNavigationBar() { previewHeaderView.onBack = { [weak self] in self?.backTapped() } view.addSubview(previewHeaderView) previewHeaderView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(68) } updateNavigationTitle() } private func setupUI() { imageView.contentMode = .scaleAspectFit imageView.clipsToBounds = true imageView.image = UIImage(systemName: "photo") imageView.tintColor = UIColor.white.withAlphaComponent(0.5) imageView.isUserInteractionEnabled = true let previousGesture = UISwipeGestureRecognizer(target: self, action: #selector(photoSwiped(_:))) previousGesture.direction = .right let nextGesture = UISwipeGestureRecognizer(target: self, action: #selector(photoSwiped(_:))) nextGesture.direction = .left imageView.addGestureRecognizer(previousGesture) imageView.addGestureRecognizer(nextGesture) bottomBar.backgroundColor = .black bottomBar.layer.borderColor = UIColor.white.withAlphaComponent(0.12).cgColor bottomBar.layer.borderWidth = 1 / UIScreen.main.scale bottomContentStack.axis = .vertical bottomContentStack.spacing = 10 comparisonBar.axis = .horizontal comparisonBar.alignment = .center comparisonBar.distribution = .equalSpacing comparisonBar.isHidden = true versionControl.selectedSegmentIndex = 1 versionControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2) versionControl.setTitleTextAttributes([.foregroundColor: UIColor.white.withAlphaComponent(0.7)], for: .normal) versionControl.setTitleTextAttributes([.foregroundColor: UIColor.white], for: .selected) versionControl.addTarget(self, action: #selector(versionChanged), for: .valueChanged) highResolutionButton.setTitle("查看高清图", for: .normal) highResolutionButton.setTitleColor(.white, for: .normal) highResolutionButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) highResolutionButton.backgroundColor = UIColor.white.withAlphaComponent(0.18) highResolutionButton.layer.cornerRadius = 16 highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside) actionStack.axis = .horizontal actionStack.distribution = .fillEqually aiEditButton.addTarget(self, action: #selector(aiEditTapped), for: .touchUpInside) actionStack.addArrangedSubview(aiEditButton) [ ("删除", "travel_album_preview_delete", "trash", #selector(deleteTapped)), ("刷新", "travel_album_preview_refresh", "arrow.clockwise", #selector(refreshTapped)), ].forEach { title, assetName, fallbackIcon, action in let button = TravelAlbumPreviewActionButton( title: title, icon: UIImage(named: assetName) ?? UIImage(systemName: fallbackIcon) ) button.addTarget(self, action: action, for: .touchUpInside) actionStack.addArrangedSubview(button) } view.addSubview(imageView) view.addSubview(bottomBar) bottomBar.addSubview(bottomContentStack) bottomContentStack.addArrangedSubview(comparisonBar) bottomContentStack.addArrangedSubview(actionStack) comparisonBar.addArrangedSubview(versionControl) comparisonBar.addArrangedSubview(highResolutionButton) bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } bottomContentStack.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.leading.trailing.equalToSuperview().inset(20) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-10) } comparisonBar.snp.makeConstraints { make in make.height.equalTo(32).priority(.high) } versionControl.snp.makeConstraints { make in make.width.equalTo(124) make.height.equalTo(32) } highResolutionButton.snp.makeConstraints { make in make.width.equalTo(112) make.height.equalTo(32) } actionStack.snp.makeConstraints { make in make.height.equalTo(58) } imageView.snp.makeConstraints { make in make.top.equalTo(previewHeaderView.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top).offset(-20) } } /// 返回相册管理页;照片预览统一使用全屏模态展示。 @objc private func backTapped() { closePreview() } /// 关闭当前预览;保留导航栈兼容分支,避免旧入口无法返回。 private func closePreview() { if let navigationController, navigationController.viewControllers.first !== self { navigationController.popViewController(animated: true) } else { dismiss(animated: true) } } /// 加载当前素材的原图,刷新时始终绕过 Kingfisher 内存缓存。 private func loadImage(forceRefresh: Bool = false) { guard let material = previewState.currentMaterial else { return } if let assetName = material.bundledImageAssetName, let bundledImage = UIImage(named: assetName) { originalImagesByMaterialID[material.id] = bundledImage originalImage = bundledImage renderCurrentPhoto() return } let imageURL = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl guard let url = URL(string: imageURL), !imageURL.isEmpty else { return } let options: KingfisherOptionsInfo = forceRefresh ? [.forceRefresh] : [] let materialID = material.id KingfisherManager.shared.retrieveImage(with: url, options: options) { [weak self] result in guard let self, case let .success(value) = result else { return } Task { @MainActor in self.originalImagesByMaterialID[materialID] = value.image guard self.previewState.currentMaterial?.id == materialID else { return } self.originalImage = value.image self.renderCurrentPhoto() } } } /// 响应左右滑动并切换至相邻照片。 @objc private func photoSwiped(_ gesture: UISwipeGestureRecognizer) { let didMove: Bool switch gesture.direction { case .left: didMove = previewState.moveNext() case .right: didMove = previewState.movePrevious() default: didMove = false } guard didMove else { return } displayCurrentPhoto() } /// 更新当前照片的标题、原图和预览内容。 private func displayCurrentPhoto() { guard let material = previewState.currentMaterial else { return } originalImage = originalImagesByMaterialID[material.id] updateNavigationTitle() renderCurrentPhoto() updateAIEditControls() if originalImage == nil { loadImage() } } /// 根据当前照片同步导航栏文件名与大小。 private func updateNavigationTitle() { guard let material = previewState.currentMaterial else { return } previewHeaderView.configure( title: material.fileName.isEmpty ? "照片预览" : material.fileName, subtitle: TravelAlbumDisplayFormatter.fileSizeText(material.fileSize), trailingText: "\(previewState.currentIndex + 1)/\(previewState.materials.count)" ) } @objc private func aiEditTapped() { guard let material = previewState.currentMaterial else { return } let state = aiEditResultStore.state(for: material.id) guard state.canStartEditing else { return } guard let originalImage else { showToast("照片加载中,请稍后再试") return } let controller = TravelAlbumAIEditSheetViewController(sourceImage: originalImage, selectedPhotoCount: 1) controller.onConfirm = { [weak self] selection in guard let self else { return } self.startAIEditing( materialID: material.id, sourceImage: originalImage, selection: selection ) } present(controller, animated: true) } /// 启动本地演示修图任务;处理期间禁用入口,完成后用新结果覆盖旧结果。 private func startAIEditing( materialID: Int, sourceImage: UIImage, selection: TravelAlbumAIEditSelection ) { let state = aiEditResultStore.state(for: materialID) guard state.canStartEditing else { return } let existingCoverTemplate = state.appliedCoverTemplateID.flatMap { templateID in TravelAlbumCoverTemplate.defaultOptions.first(where: { $0.id == templateID }) } aiEditResultStore.startProcessing(materialIDs: [materialID]) updateAIEditControls() let refinedImage = TravelAlbumAIEditImageProcessor.render( effect: selection.refinedPreset.effect, source: sourceImage ) let atmosphereImage = selection.atmosphereOption.map { option in TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage) } let coverImage = existingCoverTemplate.map { template in TravelAlbumCoverTemplateImageProcessor.render(template: template, source: refinedImage) } let resultStore = aiEditResultStore DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in // 即使用户在处理中离开预览页,也要完成任务并让相册缩略图恢复可操作状态。 if selection.refinedPreset.effect == .original, selection.atmosphereOption == nil, existingCoverTemplate == nil { resultStore.restoreOriginal(materialID: materialID) } else { resultStore.complete( materialID: materialID, presetID: selection.refinedPreset.id, atmosphereOptionID: selection.atmosphereOption?.id, coverTemplateID: existingCoverTemplate?.id, editedImage: refinedImage, atmosphereImage: atmosphereImage, coverImage: coverImage ) } guard let self else { return } if self.previewState.currentMaterial?.id == materialID { let message: String if selection.refinedPreset.effect == .original, selection.atmosphereOption == nil, existingCoverTemplate == nil { message = "已还原为原图" } else if selection.atmosphereOption != nil { message = "AI修图完成,已生成 2 个结果" } else { message = "AI修图完成" } self.showToast(message) } } } /// 根据当前照片的对比选项展示原图或最新修图结果。 private func renderCurrentPhoto() { guard let material = previewState.currentMaterial else { return } let state = aiEditResultStore.state(for: material.id) let displayImage: UIImage? switch state.displayMode { case .original: displayImage = originalImagesByMaterialID[material.id] case .edited: displayImage = aiEditResultStore.editedImage(for: material.id) ?? originalImagesByMaterialID[material.id] case .atmosphere: displayImage = aiEditResultStore.atmosphereImage(for: material.id) ?? aiEditResultStore.editedImage(for: material.id) ?? originalImagesByMaterialID[material.id] case .cover: displayImage = aiEditResultStore.coverImage(for: material.id) ?? aiEditResultStore.editedImage(for: material.id) ?? originalImagesByMaterialID[material.id] } UIView.transition(with: imageView, duration: 0.2, options: .transitionCrossDissolve) { self.imageView.image = displayImage ?? UIImage(systemName: "photo") self.imageView.tintColor = UIColor.white.withAlphaComponent(0.5) } } /// 刷新修图对比栏和 AI 按钮的可用状态。 private func updateAIEditControls() { guard let material = previewState.currentMaterial else { return } let state = aiEditResultStore.state(for: material.id) comparisonBar.isHidden = !state.hasEditedResult updateVersionControl(for: state) aiEditButton.isEnabled = state.canStartEditing aiEditButton.alpha = state.canStartEditing ? 1 : 0.35 UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() } } @objc private func versionChanged() { guard let material = previewState.currentMaterial, visibleDisplayModes.indices.contains(versionControl.selectedSegmentIndex) else { return } aiEditResultStore.selectDisplayMode( visibleDisplayModes[versionControl.selectedSegmentIndex], materialID: material.id ) renderCurrentPhoto() } /// 根据当前实际结果动态展示原图、精修后、氛围感和封面切换项。 private func updateVersionControl(for state: TravelAlbumAIEditResultState) { var modes: [TravelAlbumAIEditResultState.DisplayMode] = [.original, .edited] if state.hasAtmosphereResult { modes.append(.atmosphere) } if state.isCover { modes.append(.cover) } if visibleDisplayModes != modes { visibleDisplayModes = modes versionControl.removeAllSegments() modes.enumerated().forEach { index, mode in let title: String switch mode { case .original: title = "原图" case .edited: title = "精修后" case .atmosphere: title = "氛围感" case .cover: title = "封面" } versionControl.insertSegment(withTitle: title, at: index, animated: false) } let width: CGFloat switch modes.count { case 4: width = 220 case 3: width = 180 default: width = 124 } versionControl.snp.updateConstraints { make in make.width.equalTo(width) } } versionControl.selectedSegmentIndex = visibleDisplayModes.firstIndex(of: state.displayMode) ?? 1 } @objc private func aiEditResultDidChange(_ notification: Notification) { guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int, previewState.currentMaterial?.id == materialID else { return } renderCurrentPhoto() updateAIEditControls() } @objc private func highResolutionTapped() { guard let image = imageView.image else { return } let controller = TravelAlbumImagePreviewViewController(image: image, title: "高清图") controller.modalPresentationStyle = .fullScreen controller.modalTransitionStyle = .crossDissolve present(controller, animated: true) } @objc private func deleteTapped() { guard let material = previewState.currentMaterial else { return } guard !material.isPurchased else { showToast("已购照片不可删除") 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 } Task { do { try await self.api.deleteMaterial(id: material.id) await MainActor.run { self.originalImagesByMaterialID.removeValue(forKey: material.id) self.aiEditResultStore.remove(materialID: material.id) self.onDeleted?() if self.previewState.removeCurrent() { self.displayCurrentPhoto() } else { self.closePreview() } } } catch { await MainActor.run { self.showToast(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription) } } } }) present(alert, animated: true) } @objc private func refreshTapped() { guard let material = previewState.currentMaterial else { return } imageView.image = UIImage(systemName: "arrow.triangle.2.circlepath") imageView.tintColor = UIColor.white.withAlphaComponent(0.5) originalImage = nil originalImagesByMaterialID.removeValue(forKey: material.id) loadImage(forceRefresh: true) } private func showToast(_ message: String) { let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) present(alert, animated: true) DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak alert] in alert?.dismiss(animated: true) } } } /// 全屏预览页底部的图文操作按钮。 private final class TravelAlbumPreviewActionButton: UIButton { init(title: String, icon: UIImage?) { super.init(frame: .zero) var configuration = UIButton.Configuration.plain() configuration.image = icon configuration.title = title configuration.imagePlacement = .top configuration.imagePadding = 6 configuration.baseForegroundColor = .white configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in var attributes = attributes attributes.font = .systemFont(ofSize: 13) return attributes } self.configuration = configuration } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 相册预览统一头部,提供单一返回入口、两行标题和可选的右侧状态信息。 private final class TravelAlbumPreviewHeaderView: UIView { var onBack: (() -> Void)? private let backButton = UIButton(type: .system) private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let titleStack = UIStackView() private let trailingLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 刷新头部标题、辅助说明和右侧状态。 func configure(title: String, subtitle: String?, trailingText: String?) { titleLabel.text = title subtitleLabel.text = subtitle subtitleLabel.isHidden = subtitle?.isEmpty != false trailingLabel.text = trailingText trailingLabel.isHidden = trailingText?.isEmpty != false } private func setupUI() { backgroundColor = .black var backConfiguration = UIButton.Configuration.plain() backConfiguration.image = UIImage( systemName: "chevron.left", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold) ) backConfiguration.baseForegroundColor = .white backConfiguration.contentInsets = .zero backButton.configuration = backConfiguration backButton.backgroundColor = UIColor.white.withAlphaComponent(0.10) backButton.layer.cornerRadius = 20 backButton.accessibilityLabel = "返回" backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) titleLabel.textColor = .white titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textAlignment = .center titleLabel.lineBreakMode = .byTruncatingMiddle subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.55) subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular) subtitleLabel.textAlignment = .center titleStack.axis = .vertical titleStack.alignment = .fill titleStack.spacing = 3 titleStack.addArrangedSubview(titleLabel) titleStack.addArrangedSubview(subtitleLabel) trailingLabel.textColor = UIColor.white.withAlphaComponent(0.78) trailingLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium) trailingLabel.textAlignment = .center trailingLabel.backgroundColor = UIColor.white.withAlphaComponent(0.10) trailingLabel.layer.cornerRadius = 14 trailingLabel.clipsToBounds = true addSubview(backButton) addSubview(titleStack) addSubview(trailingLabel) backButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.width.height.equalTo(40) } trailingLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() make.width.greaterThanOrEqualTo(44) make.height.equalTo(28) } titleStack.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12) make.trailing.lessThanOrEqualTo(trailingLabel.snp.leading).offset(-12) make.width.lessThanOrEqualTo(230) } } @objc private func backTapped() { onBack?() } } /// 图片统一大图预览页,支持双指缩放并沿用相册预览的沉浸式头部。 private final class TravelAlbumImagePreviewViewController: UIViewController, UIScrollViewDelegate { private let image: UIImage private let previewTitle: String private let previewHeaderView = TravelAlbumPreviewHeaderView() private let scrollView = UIScrollView() private let imageView = UIImageView() init(image: UIImage, title: String = "高清图") { self.image = image previewTitle = title super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black previewHeaderView.configure( title: previewTitle, subtitle: "双指缩放查看细节", trailingText: nil ) previewHeaderView.onBack = { [weak self] in self?.dismiss(animated: true) } scrollView.delegate = self scrollView.minimumZoomScale = 1 scrollView.maximumZoomScale = 4 scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false imageView.image = image imageView.contentMode = .scaleAspectFit view.addSubview(previewHeaderView) view.addSubview(scrollView) scrollView.addSubview(imageView) previewHeaderView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(68) } scrollView.snp.makeConstraints { make in make.top.equalTo(previewHeaderView.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } imageView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) make.width.height.equalTo(scrollView.frameLayoutGuide) } } func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } } /// AI 修图底部弹窗,统一选择原图精修、可选氛围感修图和封面模板。 final class TravelAlbumAIEditSheetViewController: UIViewController { var onConfirm: ((TravelAlbumAIEditSelection) -> Void)? private let sourceImage: UIImage? private let selectedPhotoCount: Int private let presets = TravelAlbumEditPreset.defaultOptions private let atmosphereOptions = TravelAlbumAtmosphereEditOption.defaultOptions private let coverTemplates = TravelAlbumCoverTemplate.defaultOptions private let showsCoverTemplates: Bool private var selectedPresetID = TravelAlbumEditPreset.defaultOptions[0].id private var selectedAtmosphereOptionID: String? private var selectedCoverTemplateID: String? private let scrollView = UIScrollView() private let contentView = UIView() private let tipsContainerView = UIView() private let tipsLabel = UILabel() private let quotaInfoView = UIView() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var atmosphereCollectionView: UICollectionView! private var atmosphereDataSource: UICollectionViewDiffableDataSource! private var coverCollectionView: UICollectionView! private var coverDataSource: UICollectionViewDiffableDataSource! init(sourceImage: UIImage?, selectedPhotoCount: Int) { self.sourceImage = sourceImage self.selectedPhotoCount = max(selectedPhotoCount, 1) showsCoverTemplates = TravelAlbumCoverTemplate.shouldShow(for: selectedPhotoCount) selectedCoverTemplateID = showsCoverTemplates ? TravelAlbumCoverTemplate.defaultOptions.first?.id : nil super.init(nibName: nil, bundle: nil) modalPresentationStyle = .pageSheet if let sheetPresentationController { let contentIdentifier = UISheetPresentationController.Detent.Identifier("aiEditContent") let desiredHeight: CGFloat = showsCoverTemplates ? 920 : 770 let contentDetent = UISheetPresentationController.Detent.custom(identifier: contentIdentifier) { context in return min(desiredHeight, context.maximumDetentValue) } sheetPresentationController.detents = [contentDetent, .large()] sheetPresentationController.selectedDetentIdentifier = contentIdentifier sheetPresentationController.prefersGrabberVisible = true } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = AppColor.pageBackground setupUI() applySnapshot() } private func setupUI() { let titleLabel = UILabel() titleLabel.text = "AI修图" titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center let countLabel = UILabel() countLabel.text = "已选择 \(selectedPhotoCount) 张照片" countLabel.font = .systemFont(ofSize: 15) countLabel.textColor = AppColor.textSecondary countLabel.textAlignment = .center tipsContainerView.backgroundColor = UIColor(hex: 0xEAF2FF) tipsContainerView.layer.cornerRadius = 10 tipsLabel.font = .systemFont(ofSize: 13) tipsLabel.textColor = UIColor(hex: 0x43617F) tipsLabel.numberOfLines = 0 tipsContainerView.addSubview(tipsLabel) tipsLabel.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } let presetTitleLabel = UILabel() presetTitleLabel.text = "原图精修" presetTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) presetTitleLabel.textColor = AppColor.textPrimary let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumLineSpacing = 12 layout.itemSize = CGSize(width: 116, height: 142) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.backgroundColor = .clear collectionView.showsHorizontalScrollIndicator = false collectionView.delegate = self collectionView.register(TravelAlbumEditPresetCell.self, forCellWithReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, preset in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier, for: indexPath) as! TravelAlbumEditPresetCell cell.apply(preset: preset, sourceImage: self?.sourceImage, selected: self?.selectedPresetID == preset.id) cell.onPreview = { [weak self] in guard let self else { return } let previewImage = self.sourceImage.map { TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: $0) } self.presentStylePreview(title: preset.title, image: previewImage) } return cell } let atmosphereHeaderView = UIView() let atmosphereTitleLabel = UILabel() atmosphereTitleLabel.text = "氛围感修图" atmosphereTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) atmosphereTitleLabel.textColor = AppColor.textPrimary let optionalLabel = UILabel() optionalLabel.text = "选填" optionalLabel.font = .systemFont(ofSize: 12, weight: .medium) optionalLabel.textColor = AppColor.primary optionalLabel.backgroundColor = UIColor(hex: 0xEAF2FF) optionalLabel.textAlignment = .center optionalLabel.layer.cornerRadius = 13 optionalLabel.clipsToBounds = true atmosphereHeaderView.addSubview(atmosphereTitleLabel) atmosphereHeaderView.addSubview(optionalLabel) atmosphereTitleLabel.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() } optionalLabel.snp.makeConstraints { make in make.leading.equalTo(atmosphereTitleLabel.snp.trailing).offset(8) make.centerY.equalToSuperview() make.width.equalTo(48) make.height.equalTo(26) } let atmosphereLayout = UICollectionViewFlowLayout() atmosphereLayout.scrollDirection = .horizontal atmosphereLayout.minimumLineSpacing = 12 atmosphereLayout.itemSize = CGSize(width: 116, height: 142) atmosphereCollectionView = UICollectionView(frame: .zero, collectionViewLayout: atmosphereLayout) atmosphereCollectionView.backgroundColor = .clear atmosphereCollectionView.showsHorizontalScrollIndicator = false atmosphereCollectionView.delegate = self atmosphereCollectionView.register( TravelAlbumEditPresetCell.self, forCellWithReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier ) atmosphereDataSource = UICollectionViewDiffableDataSource( collectionView: atmosphereCollectionView ) { [weak self] collectionView, indexPath, option in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier, for: indexPath ) as! TravelAlbumEditPresetCell cell.apply( atmosphereOption: option, sourceImage: self?.sourceImage, selected: self?.selectedAtmosphereOptionID == option.id ) cell.onPreview = { [weak self] in guard let self else { return } let previewImage = self.sourceImage.map { TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: $0) } self.presentStylePreview(title: option.title, image: previewImage) } return cell } updateAtmosphereSelection() let coverHeaderView = UIView() coverHeaderView.isHidden = !showsCoverTemplates let coverTitleLabel = UILabel() coverTitleLabel.text = "封面风格模板" coverTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) coverTitleLabel.textColor = AppColor.textPrimary let freeGiftLabel = UILabel() freeGiftLabel.text = "赠送 · 不占精修张数" freeGiftLabel.font = .systemFont(ofSize: 12, weight: .medium) freeGiftLabel.textColor = UIColor(hex: 0xF29A38) freeGiftLabel.backgroundColor = UIColor(hex: 0xFFF3DE) freeGiftLabel.textAlignment = .center freeGiftLabel.layer.cornerRadius = 13 freeGiftLabel.clipsToBounds = true coverHeaderView.addSubview(coverTitleLabel) coverHeaderView.addSubview(freeGiftLabel) coverTitleLabel.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() } freeGiftLabel.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.height.equalTo(26) make.width.equalTo(148) } let coverLayout = UICollectionViewFlowLayout() coverLayout.scrollDirection = .horizontal coverLayout.minimumLineSpacing = 12 coverLayout.itemSize = CGSize(width: 116, height: 142) coverCollectionView = UICollectionView(frame: .zero, collectionViewLayout: coverLayout) coverCollectionView.backgroundColor = .clear coverCollectionView.showsHorizontalScrollIndicator = false coverCollectionView.isHidden = !showsCoverTemplates coverCollectionView.delegate = self coverCollectionView.register( TravelAlbumCoverTemplateCell.self, forCellWithReuseIdentifier: TravelAlbumCoverTemplateCell.reuseIdentifier ) coverDataSource = UICollectionViewDiffableDataSource( collectionView: coverCollectionView ) { [weak self] collectionView, indexPath, template in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumCoverTemplateCell.reuseIdentifier, for: indexPath ) as! TravelAlbumCoverTemplateCell cell.apply( template: template, sourceImage: self?.sourceImage, selected: self?.selectedCoverTemplateID == template.id ) cell.onPreview = { [weak self] in guard let self else { return } let previewImage = TravelAlbumCoverTemplateImageProcessor.render( template: template, source: self.sourceImage ) self.presentStylePreview(title: template.title, image: previewImage) } return cell } let modeTitleLabel = UILabel() modeTitleLabel.text = "修图模式" modeTitleLabel.font = .systemFont(ofSize: 15) modeTitleLabel.textColor = AppColor.textSecondary quotaInfoView.backgroundColor = .white quotaInfoView.layer.cornerRadius = 12 quotaInfoView.accessibilityLabel = "AI精修按张收费,剩余9张" quotaInfoView.accessibilityTraits = .staticText let modeLabel = UILabel() modeLabel.text = "AI精修 · 按张收费" modeLabel.font = .systemFont(ofSize: 16, weight: .medium) modeLabel.textColor = AppColor.textPrimary let quotaLabel = UILabel() quotaLabel.text = "剩余 9 张" quotaLabel.font = .systemFont(ofSize: 14, weight: .medium) quotaLabel.textColor = AppColor.primary quotaLabel.backgroundColor = UIColor(hex: 0xEAF2FF) quotaLabel.textAlignment = .center quotaLabel.layer.cornerRadius = 15 quotaLabel.clipsToBounds = true quotaInfoView.addSubview(modeLabel) quotaInfoView.addSubview(quotaLabel) modeLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() } quotaLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() make.width.equalTo(82) make.height.equalTo(30) } let cancelButton = makeBottomButton(title: "取消", isPrimary: false) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) let confirmButton = makeBottomButton(title: "确定", isPrimary: true) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton]) buttonStack.axis = .horizontal buttonStack.spacing = 12 buttonStack.distribution = .fillEqually view.addSubview(scrollView) view.addSubview(buttonStack) scrollView.addSubview(contentView) [ titleLabel, countLabel, tipsContainerView, presetTitleLabel, collectionView, atmosphereHeaderView, atmosphereCollectionView, coverHeaderView, coverCollectionView, modeTitleLabel, quotaInfoView, ].forEach(contentView.addSubview) scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = showsCoverTemplates scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(buttonStack.snp.top).offset(-12) } contentView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) make.width.equalTo(scrollView.frameLayoutGuide) } buttonStack.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(20) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) make.height.equalTo(48) } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(28) make.leading.trailing.equalToSuperview().inset(24) } countLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(12) make.leading.trailing.equalTo(titleLabel) } tipsContainerView.snp.makeConstraints { make in make.top.equalTo(countLabel.snp.bottom).offset(14) make.leading.trailing.equalToSuperview().inset(20) } presetTitleLabel.snp.makeConstraints { make in make.top.equalTo(tipsContainerView.snp.bottom).offset(20) make.leading.equalToSuperview().offset(20) } collectionView.snp.makeConstraints { make in make.top.equalTo(presetTitleLabel.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(142) } atmosphereHeaderView.snp.makeConstraints { make in make.top.equalTo(collectionView.snp.bottom).offset(20) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(26) } atmosphereCollectionView.snp.makeConstraints { make in make.top.equalTo(atmosphereHeaderView.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(142) } coverHeaderView.snp.makeConstraints { make in make.top.equalTo(atmosphereCollectionView.snp.bottom).offset(showsCoverTemplates ? 20 : 0) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(showsCoverTemplates ? 26 : 0) } coverCollectionView.snp.makeConstraints { make in make.top.equalTo(coverHeaderView.snp.bottom).offset(showsCoverTemplates ? 12 : 0) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(showsCoverTemplates ? 142 : 0) } modeTitleLabel.snp.makeConstraints { make in make.top.equalTo(coverCollectionView.snp.bottom).offset(20) make.leading.equalToSuperview().offset(20) } quotaInfoView.snp.makeConstraints { make in make.top.equalTo(modeTitleLabel.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(62) make.bottom.equalToSuperview().offset(-20) } } private func makeBottomButton(title: String, isPrimary: Bool) -> UIButton { let button = UIButton(type: .system) button.setTitle(title, for: .normal) button.setTitleColor(isPrimary ? .white : AppColor.textPrimary, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) button.backgroundColor = isPrimary ? AppColor.primary : .white button.layer.cornerRadius = 10 button.layer.borderWidth = isPrimary ? 0 : 1 button.layer.borderColor = UIColor(hex: 0xD5D5D5).cgColor return button } private func applySnapshot() { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(presets) dataSource.apply(snapshot, animatingDifferences: false) var atmosphereSnapshot = NSDiffableDataSourceSnapshot() atmosphereSnapshot.appendSections([0]) atmosphereSnapshot.appendItems(atmosphereOptions) atmosphereDataSource.apply(atmosphereSnapshot, animatingDifferences: false) guard showsCoverTemplates else { return } var coverSnapshot = NSDiffableDataSourceSnapshot() coverSnapshot.appendSections([0]) coverSnapshot.appendItems(coverTemplates) coverDataSource.apply(coverSnapshot, animatingDifferences: false) } @objc private func cancelTapped() { dismiss(animated: true) } /// 刷新氛围感修图的可选状态,并用 Tips 明确不同组合会生成几个结果。 private func updateAtmosphereSelection() { let isAtmosphereSelected = selectedAtmosphereOptionID != nil if showsCoverTemplates { tipsLabel.text = isAtmosphereSelected ? "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。" : "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成 1 个独立结果,第一张照片仍另生成封面。" } else { tipsLabel.text = isAtmosphereSelected ? "Tips:已同时选择原图精修和氛围感修图,每张照片会生成「精修后 + 氛围感」2 个独立结果。" : "Tips:原图精修、氛围感修图为两类独立模板;同时选择这两种模板时,同一张原图将并行生成两份不同的修图成品。" } } /// 展示当前样式的大图预览;原图尚未加载时给出明确提示,不改变当前选择。 private func presentStylePreview(title: String, image: UIImage?) { guard let image else { let alert = UIAlertController( title: "暂时无法预览", message: "当前照片仍在加载,请稍后再试。", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "知道了", style: .default)) present(alert, animated: true) return } let controller = TravelAlbumImagePreviewViewController(image: image, title: title) controller.modalPresentationStyle = .fullScreen controller.modalTransitionStyle = .crossDissolve present(controller, animated: true) } @objc private func confirmTapped() { guard let preset = presets.first(where: { $0.id == selectedPresetID }) else { return } let coverTemplate = selectedCoverTemplateID.flatMap { selectedID in coverTemplates.first(where: { $0.id == selectedID }) } let atmosphereOption = selectedAtmosphereOptionID.flatMap { selectedID in atmosphereOptions.first(where: { $0.id == selectedID }) } let selection = TravelAlbumAIEditSelection( refinedPreset: preset, atmosphereOption: atmosphereOption, coverTemplate: coverTemplate ) onConfirm?(selection) dismiss(animated: true) } } extension TravelAlbumAIEditSheetViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if collectionView === coverCollectionView { guard let template = coverDataSource.itemIdentifier(for: indexPath) else { return } selectedCoverTemplateID = template.id collectionView.visibleCells.compactMap { $0 as? TravelAlbumCoverTemplateCell }.forEach { cell in guard let visiblePath = collectionView.indexPath(for: cell), let visibleTemplate = coverDataSource.itemIdentifier(for: visiblePath) else { return } cell.setSelected(visibleTemplate.id == selectedCoverTemplateID) } return } if collectionView === atmosphereCollectionView { guard let option = atmosphereDataSource.itemIdentifier(for: indexPath) else { return } // 氛围感为非必选项,再次点击当前样式即可取消本次氛围感结果。 selectedAtmosphereOptionID = selectedAtmosphereOptionID == option.id ? nil : option.id collectionView.visibleCells.compactMap { $0 as? TravelAlbumEditPresetCell }.forEach { cell in guard let visiblePath = collectionView.indexPath(for: cell), let visibleOption = atmosphereDataSource.itemIdentifier(for: visiblePath) else { return } cell.setSelected(visibleOption.id == selectedAtmosphereOptionID) } updateAtmosphereSelection() return } guard let preset = dataSource.itemIdentifier(for: indexPath) else { return } selectedPresetID = preset.id collectionView.visibleCells.compactMap { $0 as? TravelAlbumEditPresetCell }.forEach { cell in guard let visiblePath = collectionView.indexPath(for: cell), let visiblePreset = dataSource.itemIdentifier(for: visiblePath) else { return } cell.setSelected(visiblePreset.id == selectedPresetID) } } } /// 带效果缩略图的修图预设单元格。 private final class TravelAlbumEditPresetCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumEditPresetCell" var onPreview: (() -> Void)? private let imageView = UIImageView() private let titleLabel = UILabel() private let selectionView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill")) private let previewButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 10 contentView.clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.backgroundColor = UIColor(hex: 0xEAF2FF) titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center titleLabel.numberOfLines = 2 selectionView.tintColor = AppColor.primary selectionView.backgroundColor = .white selectionView.layer.cornerRadius = 10 selectionView.clipsToBounds = true previewButton.setTitle("预览", for: .normal) previewButton.setTitleColor(.white, for: .normal) previewButton.titleLabel?.font = .systemFont(ofSize: 11, weight: .medium) previewButton.backgroundColor = UIColor.black.withAlphaComponent(0.58) previewButton.layer.cornerRadius = 12 previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside) contentView.addSubview(imageView) contentView.addSubview(titleLabel) contentView.addSubview(selectionView) contentView.addSubview(previewButton) imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(96) } titleLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(6) make.leading.trailing.equalToSuperview().inset(5) make.bottom.lessThanOrEqualToSuperview().offset(-5) } selectionView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(6) make.size.equalTo(20) } previewButton.snp.makeConstraints { make in make.trailing.bottom.equalTo(imageView).inset(6) make.width.equalTo(46) make.height.equalTo(24) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(preset: TravelAlbumEditPreset, sourceImage: UIImage?, selected: Bool) { apply( title: preset.title, effect: preset.effect, sourceImage: sourceImage, selected: selected ) } /// 更新氛围感样式的效果图、名称和单选状态。 func apply( atmosphereOption: TravelAlbumAtmosphereEditOption, sourceImage: UIImage?, selected: Bool ) { apply( title: atmosphereOption.title, effect: atmosphereOption.effect, sourceImage: sourceImage, selected: selected ) accessibilityHint = "再次点击已选样式可取消,点击预览按钮查看大图" } private func apply( title: String, effect: TravelAlbumEditPreset.Effect, sourceImage: UIImage?, selected: Bool ) { titleLabel.text = title imageView.image = sourceImage.map { TravelAlbumAIEditImageProcessor.render(effect: effect, source: $0) } ?? UIImage(systemName: "photo") setSelected(selected) accessibilityLabel = title accessibilityValue = selected ? "已选中" : "未选中" } func setSelected(_ selected: Bool) { contentView.layer.borderWidth = selected ? 2 : 0 contentView.layer.borderColor = selected ? AppColor.primary.cgColor : UIColor.clear.cgColor selectionView.isHidden = !selected accessibilityValue = selected ? "已选中" : "未选中" } override func prepareForReuse() { super.prepareForReuse() onPreview = nil accessibilityHint = nil } @objc private func previewTapped() { onPreview?() } } /// 带本地排版效果图的封面模板单元格。 private final class TravelAlbumCoverTemplateCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumCoverTemplateCell" var onPreview: (() -> Void)? private let imageView = UIImageView() private let titleLabel = UILabel() private let selectionView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill")) private let previewButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 10 contentView.clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.backgroundColor = UIColor(hex: 0xEAF2FF) titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center selectionView.tintColor = AppColor.primary selectionView.backgroundColor = .white selectionView.layer.cornerRadius = 10 selectionView.clipsToBounds = true previewButton.setTitle("预览", for: .normal) previewButton.setTitleColor(.white, for: .normal) previewButton.titleLabel?.font = .systemFont(ofSize: 11, weight: .medium) previewButton.backgroundColor = UIColor.black.withAlphaComponent(0.58) previewButton.layer.cornerRadius = 12 previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside) contentView.addSubview(imageView) contentView.addSubview(titleLabel) contentView.addSubview(selectionView) contentView.addSubview(previewButton) imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(104) } titleLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(6) make.leading.trailing.equalToSuperview().inset(5) make.bottom.lessThanOrEqualToSuperview().offset(-5) } selectionView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(6) make.size.equalTo(20) } previewButton.snp.makeConstraints { make in make.trailing.bottom.equalTo(imageView).inset(6) make.width.equalTo(46) make.height.equalTo(24) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新模板效果图与单选状态。 func apply(template: TravelAlbumCoverTemplate, sourceImage: UIImage?, selected: Bool) { titleLabel.text = template.title imageView.image = TravelAlbumCoverTemplateImageProcessor.render( template: template, source: sourceImage, targetSize: CGSize(width: 232, height: 208) ) setSelected(selected) accessibilityLabel = "\(template.title)封面模板" accessibilityValue = selected ? "已选中" : "未选中" accessibilityHint = "点击预览按钮查看完整模板样式" } /// 刷新当前模板的选中描边和勾选标识。 func setSelected(_ selected: Bool) { contentView.layer.borderWidth = selected ? 2 : 0 contentView.layer.borderColor = selected ? AppColor.primary.cgColor : UIColor.clear.cgColor selectionView.isHidden = !selected accessibilityValue = selected ? "已选中" : "未选中" } override func prepareForReuse() { super.prepareForReuse() onPreview = nil } @objc private func previewTapped() { onPreview?() } }