1352 lines
58 KiB
Swift
1352 lines
58 KiB
Swift
//
|
||
// 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<Int, TravelAlbumEditPreset>!
|
||
private var atmosphereCollectionView: UICollectionView!
|
||
private var atmosphereDataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumAtmosphereEditOption>!
|
||
private var coverCollectionView: UICollectionView!
|
||
private var coverDataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumCoverTemplate>!
|
||
|
||
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<Int, TravelAlbumEditPreset>(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<Int, TravelAlbumAtmosphereEditOption>(
|
||
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<Int, TravelAlbumCoverTemplate>(
|
||
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<Int, TravelAlbumEditPreset>()
|
||
snapshot.appendSections([0])
|
||
snapshot.appendItems(presets)
|
||
dataSource.apply(snapshot, animatingDifferences: false)
|
||
|
||
var atmosphereSnapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumAtmosphereEditOption>()
|
||
atmosphereSnapshot.appendSections([0])
|
||
atmosphereSnapshot.appendItems(atmosphereOptions)
|
||
atmosphereDataSource.apply(atmosphereSnapshot, animatingDifferences: false)
|
||
|
||
guard showsCoverTemplates else { return }
|
||
var coverSnapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumCoverTemplate>()
|
||
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?()
|
||
}
|
||
}
|