feat: 优化AI修图模板选择界面
This commit is contained in:
@@ -52,6 +52,28 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
workflow.visibleCategories
|
||||
}
|
||||
|
||||
/// 当前工作流选择的原始照片数量。
|
||||
var selectedPhotoCount: Int {
|
||||
switch workflow {
|
||||
case .initial(_, let materialIds): materialIds.count
|
||||
case .reretouch: 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否展示首次修图的氛围感与封面生成说明。
|
||||
var shouldShowInitialTips: Bool {
|
||||
if case .initial = workflow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// 首次修图页按实际展示分组生成的规则说明。
|
||||
var initialTipsText: String {
|
||||
if visibleCategories.contains(.cover) {
|
||||
return "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。"
|
||||
}
|
||||
return "Tips:本次显示 2 类风格。氛围感修图为选填;选中后每张照片会生成「精修后 + 氛围感」2 个独立结果。"
|
||||
}
|
||||
|
||||
/// 当前分组是否为选填。
|
||||
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||
workflow.isOptional(category)
|
||||
|
||||
@@ -11,19 +11,22 @@ import UIKit
|
||||
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
/// 页面使用的 collection section。
|
||||
private enum Section: Hashable {
|
||||
case overview
|
||||
case templates(TravelAlbumAIRetouchTemplateCategory)
|
||||
case mode
|
||||
}
|
||||
|
||||
/// 页面使用的 diffable item。
|
||||
private enum Item: Hashable {
|
||||
case overview
|
||||
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
||||
case mode
|
||||
}
|
||||
|
||||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let onSubmitted: () -> Void
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private let statusContainer = UIView()
|
||||
@@ -33,7 +36,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
private let bottomBar = UIView()
|
||||
private let bottomDivider = UIView()
|
||||
private let footerStack = UIStackView()
|
||||
private let quotaLabel = UILabel()
|
||||
private let validationLabel = UILabel()
|
||||
private let actionStack = UIStackView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
@@ -62,12 +64,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
||||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
||||
|
||||
titleLabel.text = "AI修图"
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.accessibilityTraits = .header
|
||||
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.alwaysBounceVertical = true
|
||||
@@ -77,6 +73,14 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
TravelAlbumAIRetouchTemplateCell.self,
|
||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
||||
)
|
||||
collectionView.register(
|
||||
TravelAlbumAIRetouchOverviewCell.self,
|
||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
|
||||
)
|
||||
collectionView.register(
|
||||
TravelAlbumAIRetouchModeCell.self,
|
||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
||||
)
|
||||
collectionView.register(
|
||||
TravelAlbumAIRetouchSectionHeader.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
@@ -104,10 +108,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
footerStack.axis = .vertical
|
||||
footerStack.spacing = 8
|
||||
footerStack.alignment = .fill
|
||||
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
||||
quotaLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
quotaLabel.textAlignment = .center
|
||||
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
|
||||
actionStack.axis = .horizontal
|
||||
actionStack.spacing = 12
|
||||
actionStack.distribution = .fillEqually
|
||||
@@ -116,11 +116,9 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
configureConfirmButton()
|
||||
actionStack.addArrangedSubview(cancelButton)
|
||||
actionStack.addArrangedSubview(confirmButton)
|
||||
footerStack.addArrangedSubview(quotaLabel)
|
||||
footerStack.addArrangedSubview(validationLabel)
|
||||
footerStack.addArrangedSubview(actionStack)
|
||||
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(collectionView)
|
||||
view.addSubview(statusContainer)
|
||||
statusContainer.addSubview(statusIndicator)
|
||||
@@ -132,11 +130,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(18)
|
||||
make.height.equalTo(30)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
@@ -150,10 +143,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
actionStack.snp.makeConstraints { make in
|
||||
make.height.equalTo(48)
|
||||
make.height.equalTo(56)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
@@ -212,9 +205,9 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.title = "取消"
|
||||
configuration.baseBackgroundColor = .white
|
||||
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary
|
||||
configuration.background.cornerRadius = 14
|
||||
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55)
|
||||
configuration.baseForegroundColor = AIRetouchTemplateStyle.textPrimary
|
||||
configuration.background.cornerRadius = 12
|
||||
configuration.background.strokeColor = UIColor(hex: 0xD1D1D6)
|
||||
configuration.background.strokeWidth = 1
|
||||
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
||||
cancelButton.configuration = configuration
|
||||
@@ -226,7 +219,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
configuration.title = "确定"
|
||||
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
|
||||
configuration.baseForegroundColor = .white
|
||||
configuration.background.cornerRadius = 14
|
||||
configuration.background.cornerRadius = 12
|
||||
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
||||
confirmButton.configuration = configuration
|
||||
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
||||
@@ -245,6 +238,16 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
[weak self] collectionView, indexPath, item in
|
||||
guard let self else { return nil }
|
||||
switch item {
|
||||
case .overview:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchOverviewCell
|
||||
cell.apply(
|
||||
selectedPhotoCount: self.viewModel.selectedPhotoCount,
|
||||
tips: self.viewModel.shouldShowInitialTips ? self.viewModel.initialTipsText : nil
|
||||
)
|
||||
return cell
|
||||
case .template(let category, let template):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
||||
@@ -254,22 +257,49 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
template: template,
|
||||
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
||||
)
|
||||
cell.onPreviewTapped = { [weak self] in
|
||||
self?.showPreview(for: template)
|
||||
}
|
||||
return cell
|
||||
case .mode:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchModeCell
|
||||
cell.apply(
|
||||
remainingQuota: self.viewModel.remainingQuota,
|
||||
insufficient: self.viewModel.remainingQuota.map {
|
||||
self.viewModel.requiredQuota > $0
|
||||
} ?? false
|
||||
)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||
guard kind == UICollectionView.elementKindSectionHeader,
|
||||
let self,
|
||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section),
|
||||
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
|
||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
|
||||
return nil
|
||||
}
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
|
||||
let header = collectionView.dequeueReusableSupplementaryView(
|
||||
ofKind: kind,
|
||||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchSectionHeader
|
||||
header.apply(title: category.title, optional: self.viewModel.isOptional(category))
|
||||
switch section {
|
||||
case .templates(let category):
|
||||
header.apply(
|
||||
title: category.title,
|
||||
badge: category == .cover
|
||||
? .gift
|
||||
: (self.viewModel.isOptional(category) ? .optional : nil)
|
||||
)
|
||||
case .mode:
|
||||
header.apply(title: "修图模式", badge: nil)
|
||||
case .overview:
|
||||
return nil
|
||||
}
|
||||
return header
|
||||
}
|
||||
}
|
||||
@@ -281,27 +311,68 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
||||
return nil
|
||||
}
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .absolute(118),
|
||||
heightDimension: .absolute(154)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||||
section.interGroupSpacing = 12
|
||||
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
|
||||
section.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(40)
|
||||
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
|
||||
case .overview:
|
||||
let size = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 160 : 74)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: size)
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
|
||||
return NSCollectionLayoutSection(group: group)
|
||||
case .templates:
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .absolute(118),
|
||||
heightDimension: .absolute(154)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||||
section.interGroupSpacing = 12
|
||||
section.contentInsets = NSDirectionalEdgeInsets(
|
||||
top: 4,
|
||||
leading: 18,
|
||||
bottom: 12,
|
||||
trailing: 18
|
||||
)
|
||||
section.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(40)
|
||||
),
|
||||
elementKind: UICollectionView.elementKindSectionHeader,
|
||||
alignment: .top
|
||||
),
|
||||
elementKind: UICollectionView.elementKindSectionHeader,
|
||||
alignment: .top
|
||||
),
|
||||
]
|
||||
return section
|
||||
]
|
||||
return section
|
||||
case .mode:
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(64)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.contentInsets = NSDirectionalEdgeInsets(
|
||||
top: 4,
|
||||
leading: 18,
|
||||
bottom: 18,
|
||||
trailing: 18
|
||||
)
|
||||
section.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(42)
|
||||
),
|
||||
elementKind: UICollectionView.elementKindSectionHeader,
|
||||
alignment: .top
|
||||
),
|
||||
]
|
||||
return section
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,17 +397,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
|
||||
validationLabel.text = viewModel.validationMessage
|
||||
validationLabel.isHidden = viewModel.validationMessage == nil
|
||||
if let remainingQuota = viewModel.remainingQuota {
|
||||
quotaLabel.text = "剩余 \(remainingQuota) 次"
|
||||
quotaLabel.accessibilityLabel = "剩余\(remainingQuota)次修图额度"
|
||||
quotaLabel.textColor = viewModel.requiredQuota > remainingQuota
|
||||
? AIRetouchTemplateStyle.danger
|
||||
: AIRetouchTemplateStyle.primary
|
||||
} else {
|
||||
quotaLabel.text = "剩余 -- 次"
|
||||
quotaLabel.accessibilityLabel = "剩余修图额度暂不可用"
|
||||
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||
}
|
||||
cancelButton.isEnabled = !viewModel.isSubmitting
|
||||
confirmButton.isEnabled = viewModel.canSubmit
|
||||
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
||||
@@ -350,9 +410,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
|
||||
private func applySnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.overview])
|
||||
snapshot.appendItems([.overview], toSection: .overview)
|
||||
for category in viewModel.visibleCategories {
|
||||
appendTemplates(category, to: &snapshot)
|
||||
}
|
||||
snapshot.appendSections([.mode])
|
||||
snapshot.appendItems([.mode], toSection: .mode)
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
@@ -385,16 +449,104 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
@objc private func retryTapped() {
|
||||
Task { await viewModel.loadTemplates(api: api) }
|
||||
}
|
||||
|
||||
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||||
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
|
||||
controller.modalPresentationStyle = .fullScreen
|
||||
present(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .template(let category, let template) = item else { return }
|
||||
collectionView.scrollToItem(
|
||||
at: indexPath,
|
||||
at: .centeredHorizontally,
|
||||
animated: !UIAccessibility.isReduceMotionEnabled
|
||||
)
|
||||
viewModel.toggleTemplate(id: template.id, category: category)
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板页顶部说明卡,随模板列表一起滚动。
|
||||
final class TravelAlbumAIRetouchOverviewCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchOverviewCell"
|
||||
|
||||
private let stackView = UIStackView()
|
||||
private let titleLabel = UILabel()
|
||||
private let selectionCountLabel = UILabel()
|
||||
private let tipsContainer = UIView()
|
||||
private let tipsLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
titleLabel.text = "AI修图"
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.accessibilityTraits = .header
|
||||
|
||||
selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||
selectionCountLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(
|
||||
for: .systemFont(ofSize: 17, weight: .regular)
|
||||
)
|
||||
selectionCountLabel.adjustsFontForContentSizeCategory = true
|
||||
selectionCountLabel.textAlignment = .center
|
||||
selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel"
|
||||
|
||||
tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground
|
||||
tipsContainer.layer.cornerRadius = 10
|
||||
tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer"
|
||||
tipsLabel.textColor = AIRetouchTemplateStyle.tipsText
|
||||
tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(
|
||||
for: .systemFont(ofSize: 13, weight: .regular)
|
||||
)
|
||||
tipsLabel.adjustsFontForContentSizeCategory = true
|
||||
tipsLabel.numberOfLines = 0
|
||||
tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel"
|
||||
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .fill
|
||||
stackView.spacing = 4
|
||||
stackView.addArrangedSubview(titleLabel)
|
||||
stackView.addArrangedSubview(selectionCountLabel)
|
||||
stackView.addArrangedSubview(tipsContainer)
|
||||
stackView.setCustomSpacing(12, after: selectionCountLabel)
|
||||
|
||||
contentView.addSubview(stackView)
|
||||
tipsContainer.addSubview(tipsLabel)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.leading.trailing.equalToSuperview().inset(18)
|
||||
make.bottom.lessThanOrEqualToSuperview().offset(-10)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.height.equalTo(30)
|
||||
}
|
||||
selectionCountLabel.snp.makeConstraints { make in
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
tipsLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新选中照片数量和当前工作流说明。
|
||||
func apply(selectedPhotoCount: Int, tips: String?) {
|
||||
selectionCountLabel.text = "已选择 \(selectedPhotoCount) 张照片"
|
||||
tipsLabel.text = tips
|
||||
tipsContainer.isHidden = tips == nil
|
||||
tipsLabel.isHidden = tips == nil
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
|
||||
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
||||
@@ -402,6 +554,9 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
private let previewImageView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
|
||||
|
||||
var onPreviewTapped: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -421,10 +576,26 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
checkView.tintColor = AIRetouchTemplateStyle.primary
|
||||
checkView.backgroundColor = .white
|
||||
checkView.layer.cornerRadius = 10
|
||||
var previewConfiguration = UIButton.Configuration.filled()
|
||||
previewConfiguration.title = "预览"
|
||||
previewConfiguration.baseForegroundColor = .white
|
||||
previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58)
|
||||
previewConfiguration.background.cornerRadius = 15
|
||||
previewConfiguration.contentInsets = .zero
|
||||
previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
|
||||
attributes in
|
||||
var attributes = attributes
|
||||
attributes.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
return attributes
|
||||
}
|
||||
previewButton.configuration = previewConfiguration
|
||||
previewButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewButton"
|
||||
previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
|
||||
|
||||
contentView.addSubview(previewImageView)
|
||||
contentView.addSubview(nameLabel)
|
||||
contentView.addSubview(checkView)
|
||||
contentView.addSubview(previewButton)
|
||||
previewImageView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(116)
|
||||
@@ -438,6 +609,11 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
make.top.trailing.equalToSuperview().inset(8)
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
previewButton.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalTo(previewImageView).inset(8)
|
||||
make.width.equalTo(58)
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -449,6 +625,7 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
super.prepareForReuse()
|
||||
previewImageView.kf.cancelDownloadTask()
|
||||
previewImageView.image = nil
|
||||
onPreviewTapped = nil
|
||||
}
|
||||
|
||||
/// 更新模板内容与选中状态。
|
||||
@@ -478,9 +655,84 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
accessibilityValue = selected ? "已选择" : "未选择"
|
||||
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||
}
|
||||
|
||||
@objc private func previewTapped() {
|
||||
onPreviewTapped?()
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板分组标题,可附带“选填”标签。
|
||||
/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
|
||||
private final class AIRetouchTemplatePreviewButton: UIButton {
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
bounds.insetBy(dx: -6, dy: -6).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图计费模式卡,展示按张收费规则与剩余额度。
|
||||
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
|
||||
|
||||
private let modeLabel = UILabel()
|
||||
private let quotaContainer = UIView()
|
||||
private let quotaLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = 12
|
||||
modeLabel.text = "AI精修 · 按张收费"
|
||||
modeLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
modeLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
quotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
quotaContainer.layer.cornerRadius = 15
|
||||
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
||||
quotaLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
quotaLabel.textAlignment = .center
|
||||
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
|
||||
|
||||
contentView.addSubview(modeLabel)
|
||||
contentView.addSubview(quotaContainer)
|
||||
quotaContainer.addSubview(quotaLabel)
|
||||
modeLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(quotaContainer.snp.leading).offset(-12)
|
||||
}
|
||||
quotaContainer.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.equalTo(96)
|
||||
make.height.equalTo(30)
|
||||
}
|
||||
quotaLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新剩余可精修张数,并在额度不足时切换为警示色。
|
||||
func apply(remainingQuota: Int?, insufficient: Bool) {
|
||||
quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张"
|
||||
quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" }
|
||||
?? "剩余修图额度暂不可用"
|
||||
quotaLabel.textColor = insufficient
|
||||
? AIRetouchTemplateStyle.danger
|
||||
: (remainingQuota == nil ? AIRetouchTemplateStyle.textSecondary : AIRetouchTemplateStyle.primary)
|
||||
quotaContainer.backgroundColor = quotaLabel.textColor.withAlphaComponent(0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板分组标题右侧的业务标记。
|
||||
fileprivate enum AIRetouchTemplateSectionBadge {
|
||||
case optional
|
||||
case gift
|
||||
}
|
||||
|
||||
/// AI 修图模板分组标题,可附带选填或赠送标记。
|
||||
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||
|
||||
@@ -491,12 +743,9 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
super.init(frame: frame)
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
optionalLabel.text = "选填"
|
||||
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
optionalLabel.textAlignment = .center
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
optionalLabel.layer.cornerRadius = 8
|
||||
optionalLabel.layer.cornerRadius = 10
|
||||
optionalLabel.clipsToBounds = true
|
||||
|
||||
addSubview(titleLabel)
|
||||
@@ -508,7 +757,7 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
optionalLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.width.equalTo(38)
|
||||
make.width.greaterThanOrEqualTo(38)
|
||||
make.height.equalTo(20)
|
||||
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||
}
|
||||
@@ -519,21 +768,163 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新分组标题与选填标签。
|
||||
func apply(title: String, optional: Bool) {
|
||||
/// 更新分组标题与业务标记。
|
||||
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
||||
titleLabel.text = title
|
||||
optionalLabel.isHidden = !optional
|
||||
accessibilityLabel = optional ? "\(title),选填" : title
|
||||
optionalLabel.isHidden = badge == nil
|
||||
switch badge {
|
||||
case .optional:
|
||||
optionalLabel.text = " 选填 "
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
case .gift:
|
||||
optionalLabel.text = " 赠送 · 不占精修张数 "
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.gift
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.giftBackground
|
||||
case nil:
|
||||
optionalLabel.text = nil
|
||||
}
|
||||
let badgeText = optionalLabel.text?.trimmingCharacters(in: .whitespaces)
|
||||
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
|
||||
accessibilityTraits = .header
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板全屏预览,支持双指缩放查看模板细节。
|
||||
final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController {
|
||||
private let template: TravelAlbumAIRetouchTemplate
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let scrollView = UIScrollView()
|
||||
private let imageView = UIImageView()
|
||||
|
||||
/// 创建指定模板的全屏预览页。
|
||||
init(template: TravelAlbumAIRetouchTemplate) {
|
||||
self.template = template
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
loadPreview()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .black
|
||||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview"
|
||||
|
||||
var backConfiguration = UIButton.Configuration.filled()
|
||||
backConfiguration.image = UIImage(systemName: "chevron.left")
|
||||
backConfiguration.baseForegroundColor = .white
|
||||
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
backConfiguration.background.cornerRadius = 24
|
||||
backButton.configuration = backConfiguration
|
||||
backButton.accessibilityLabel = "返回模板选择"
|
||||
backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton"
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
|
||||
titleLabel.text = template.name
|
||||
titleLabel.textColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
subtitleLabel.text = "双指缩放查看细节"
|
||||
subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||
subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
||||
subtitleLabel.textAlignment = .center
|
||||
|
||||
scrollView.minimumZoomScale = 1
|
||||
scrollView.maximumZoomScale = 4
|
||||
scrollView.delegate = self
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView"
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.backgroundColor = UIColor(hex: 0x111111)
|
||||
imageView.accessibilityLabel = "\(template.name)模板预览图"
|
||||
|
||||
view.addSubview(backButton)
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(subtitleLabel)
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(imageView)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
|
||||
make.centerX.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualToSuperview().offset(-66)
|
||||
make.height.equalTo(26)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(2)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.center.equalTo(scrollView.frameLayoutGuide)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||
make.height.equalTo(imageView.snp.width).multipliedBy(0.75)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPreview() {
|
||||
guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
|
||||
imageView.image = UIImage(systemName: "photo")
|
||||
imageView.tintColor = UIColor.white.withAlphaComponent(0.45)
|
||||
return
|
||||
}
|
||||
imageView.kf.setImage(
|
||||
with: url,
|
||||
placeholder: UIImage(systemName: "photo")?.withTintColor(
|
||||
UIColor.white.withAlphaComponent(0.45),
|
||||
renderingMode: .alwaysOriginal
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate {
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
imageView
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板页视觉常量。
|
||||
private enum AIRetouchTemplateStyle {
|
||||
static let primary = UIColor(hex: 0x1677FF)
|
||||
static let pageBackground = UIColor(hex: 0xF7F9FC)
|
||||
static let pageBackground = UIColor(hex: 0xF7F7F7)
|
||||
static let textPrimary = UIColor(hex: 0x172033)
|
||||
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
||||
static let textSecondary = UIColor(hex: 0x6E6E73)
|
||||
static let tipsBackground = UIColor(hex: 0xEAF3FF)
|
||||
static let tipsText = UIColor(hex: 0x385778)
|
||||
static let gift = UIColor(hex: 0xFF9418)
|
||||
static let giftBackground = UIColor(hex: 0xFFF1D9)
|
||||
static let border = UIColor(hex: 0xDCE4EF)
|
||||
static let danger = UIColor(hex: 0xE53935)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user