Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
T
2026-07-31 16:48:28 +08:00

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