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?()
|
|
}
|
|
}
|