Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift
T
lujiuyin 396597a160 feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
2026-08-27 17:15:50 +08:00

393 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SnapKit
import UIKit
/// 相册自动修图设置:在同一页切换修图方式、展开模板并确认草稿。
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
/// 确认草稿后通知调用方保存相册配置。
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
private let api: any TravelAlbumServing
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let headerStack = UIStackView()
private let modeStack = UIStackView()
private let originalOption = AutoRetouchModeControl(title: "不修图", detail: "保留相机原始照片", symbol: "photo")
private let aiOption = AutoRetouchModeControl(title: "AI 修图", detail: "上传后自动套用模板", symbol: "wand.and.stars")
private let contentRegion = UIView()
private let templateHeader = UIStackView()
private lazy var templateCollectionView = UICollectionView(frame: .zero, collectionViewLayout: makeTemplateLayout())
private var templateDataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>!
private let originalHint = UIStackView()
private let statusStack = UIStackView()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let footer = UIView()
private let selectionLabel = UILabel()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var lastTemplatesVisible: Bool?
/// 创建设置 Sheet;方式选择与模板区域共用同一份配置草稿。
init(viewModel: TravelAlbumAutoRetouchSettingViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheet = sheetPresentationController {
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
sheet.prefersGrabberVisible = true
sheet.preferredCornerRadius = 24
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF6F8FC)
titleLabel.text = viewModel.allowsModeSelection ? "选择修图方式" : "选择修图模板"
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.accessibilityTraits = .header
subtitleLabel.text = "设置仅对后续上传的照片生效"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
headerStack.axis = .vertical
headerStack.spacing = 8
headerStack.addArrangedSubview(titleLabel)
headerStack.addArrangedSubview(subtitleLabel)
headerStack.setCustomSpacing(20, after: subtitleLabel)
modeStack.axis = .horizontal
modeStack.distribution = .fillEqually
modeStack.spacing = 12
modeStack.accessibilityIdentifier = "travelAlbum.autoRetouchModeOptions"
originalOption.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalOption"
aiOption.accessibilityIdentifier = "travelAlbum.autoRetouchAIOption"
modeStack.addArrangedSubview(originalOption)
modeStack.addArrangedSubview(aiOption)
headerStack.addArrangedSubview(modeStack)
modeStack.isHidden = !viewModel.allowsModeSelection
let templateTitle = UILabel()
templateTitle.text = viewModel.allowsModeSelection ? "选择修图模板" : "精修模板"
templateTitle.font = .systemFont(ofSize: 17, weight: .semibold)
templateTitle.textColor = AppColor.textPrimary
templateTitle.accessibilityTraits = .header
let templateTips = UILabel()
templateTips.text = "单选模板 · 点击预览查看前后效果"
templateTips.font = .systemFont(ofSize: 12)
templateTips.textColor = AppColor.textSecondary
templateHeader.axis = .vertical
templateHeader.spacing = 5
templateHeader.addArrangedSubview(templateTitle)
templateHeader.addArrangedSubview(templateTips)
templateCollectionView.backgroundColor = .clear
templateCollectionView.alwaysBounceVertical = true
templateCollectionView.accessibilityIdentifier = "travelAlbum.autoRetouchTemplateCollection"
templateCollectionView.delegate = self
templateCollectionView.register(TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier)
configureDataSource()
let hintIcon = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
hintIcon.tintColor = AppColor.primary.withAlphaComponent(0.6)
hintIcon.contentMode = .scaleAspectFit
let hintTitle = UILabel()
hintTitle.text = "保留原图,直接上传"
hintTitle.font = .systemFont(ofSize: 17, weight: .semibold)
hintTitle.textColor = AppColor.textPrimary
let hintDetail = UILabel()
hintDetail.text = "选择上方 AI 修图,即可在这里挑选模板"
hintDetail.font = .systemFont(ofSize: 13)
hintDetail.textColor = AppColor.textSecondary
hintDetail.numberOfLines = 2
hintDetail.textAlignment = .center
originalHint.axis = .vertical
originalHint.alignment = .center
originalHint.spacing = 12
originalHint.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalHint"
originalHint.addArrangedSubview(hintIcon)
originalHint.addArrangedSubview(hintTitle)
originalHint.addArrangedSubview(hintDetail)
hintIcon.snp.makeConstraints { $0.size.equalTo(48) }
statusStack.axis = .vertical
statusStack.alignment = .center
statusStack.spacing = 12
statusStack.addArrangedSubview(activityIndicator)
statusStack.addArrangedSubview(statusLabel)
statusStack.addArrangedSubview(retryButton)
activityIndicator.color = AppColor.primary
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textColor = AppColor.textSecondary
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重新加载", for: .normal)
retryButton.tintColor = AppColor.primary
retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
retryButton.snp.makeConstraints { $0.height.equalTo(44) }
footer.backgroundColor = .white
footer.layer.shadowColor = UIColor(hex: 0x182B49).cgColor
footer.layer.shadowOpacity = 0.04
footer.layer.shadowRadius = 12
footer.layer.shadowOffset = CGSize(width: 0, height: -3)
selectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
selectionLabel.textColor = AppColor.textSecondary
selectionLabel.lineBreakMode = .byTruncatingTail
selectionLabel.accessibilityIdentifier = "travelAlbum.autoRetouchSelectionSummary"
configureAction(cancelButton, title: "取消", filled: false)
configureAction(confirmButton, title: "确定", filled: true)
confirmButton.accessibilityIdentifier = "travelAlbum.autoRetouchConfirmButton"
view.addSubview(headerStack)
view.addSubview(contentRegion)
contentRegion.addSubview(templateHeader)
contentRegion.addSubview(templateCollectionView)
contentRegion.addSubview(originalHint)
contentRegion.addSubview(statusStack)
view.addSubview(footer)
footer.addSubview(selectionLabel)
footer.addSubview(cancelButton)
footer.addSubview(confirmButton)
}
override func setupConstraints() {
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
contentRegion.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(22)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(footer.snp.top).offset(-8)
}
templateHeader.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(16)
}
templateCollectionView.snp.makeConstraints { make in
make.top.equalTo(templateHeader.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
}
originalHint.snp.makeConstraints { make in
make.centerY.equalToSuperview().offset(-12)
make.leading.trailing.equalToSuperview().inset(24)
}
statusStack.snp.makeConstraints { make in
make.center.equalTo(templateCollectionView)
make.leading.trailing.equalToSuperview().inset(32)
}
footer.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
selectionLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.trailing.equalToSuperview().inset(16)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(selectionLabel.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(50)
make.width.equalTo(96)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
confirmButton.snp.makeConstraints { make in
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.equalTo(cancelButton)
}
}
override func bindActions() {
originalOption.addTarget(self, action: #selector(originalTapped), for: .touchUpInside)
aiOption.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureDataSource() {
templateDataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>(
collectionView: templateCollectionView
) { [weak self] collectionView, indexPath, template in
guard let self else { return nil }
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, for: indexPath
) as! TravelAlbumAIRetouchTemplateCell
cell.apply(template: template, selected: self.viewModel.selectedTemplateId == template.id)
cell.onPreviewTapped = { [weak self] in self?.showPreview(for: template) }
return cell
}
}
private func makeTemplateLayout() -> UICollectionViewCompositionalLayout {
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)
))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(156)),
subitem: item, count: 3
)
group.interItemSpacing = .fixed(12)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 16, bottom: 12, trailing: 16)
return UICollectionViewCompositionalLayout(section: section)
}
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
guard presentedViewController == nil else { return }
guard let content = template.comparisonContent else { showToast("暂无对比预览"); return }
present(BeforeAfterComparisonViewController(viewModel: BeforeAfterComparisonViewModel(content: content)), animated: true)
}
@MainActor
private func applyViewModel() {
let showsTemplates = viewModel.isEnabled
originalOption.apply(selected: !showsTemplates)
aiOption.apply(selected: showsTemplates)
confirmButton.isEnabled = viewModel.canConfirm
confirmButton.alpha = viewModel.canConfirm ? 1 : 0.4
selectionLabel.text = !showsTemplates ? "将保留原图,不进行 AI 修图"
: viewModel.selectedTemplateName.map { "已选择:\($0)" } ?? "请选择一个修图模板"
selectionLabel.textColor = showsTemplates && viewModel.selectedTemplateName != nil ? AppColor.primary : AppColor.textSecondary
let updateVisibility = {
self.originalHint.isHidden = showsTemplates
self.templateHeader.isHidden = !showsTemplates
self.templateCollectionView.isHidden = !showsTemplates || self.viewModel.isLoading || self.viewModel.errorMessage != nil
self.statusStack.isHidden = !showsTemplates || (!self.viewModel.isLoading && self.viewModel.errorMessage == nil)
}
if let previous = lastTemplatesVisible, previous != showsTemplates,
view.window != nil, !UIAccessibility.isReduceMotionEnabled {
UIView.transition(with: contentRegion, duration: 0.2, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: updateVisibility)
} else {
updateVisibility()
}
lastTemplatesVisible = showsTemplates
if viewModel.isLoading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
activityIndicator.isHidden = !viewModel.isLoading
statusLabel.text = viewModel.isLoading ? "正在加载修图模板…" : viewModel.errorMessage
retryButton.isHidden = viewModel.isLoading
let previousTemplates = Set(templateDataSource.snapshot().itemIdentifiers)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumAIRetouchTemplate>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.templates)
snapshot.reconfigureItems(viewModel.templates.filter(previousTemplates.contains))
templateDataSource.apply(snapshot, animatingDifferences: true)
}
private func configureAction(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
button.layer.cornerRadius = 14
button.backgroundColor = filled ? AppColor.primary : .white
button.setTitleColor(filled ? .white : AppColor.textSecondary, for: .normal)
button.layer.borderWidth = filled ? 0 : 1
button.layer.borderColor = UIColor(hex: 0xDFE5EF).cgColor
}
@objc private func originalTapped() { viewModel.selectMode(enabled: false) }
@objc private func aiTapped() { viewModel.selectMode(enabled: true) }
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
@objc private func confirmTapped() {
guard viewModel.canConfirm, let configuration = viewModel.pendingConfiguration else { return }
let completion = onConfirm
dismiss(animated: true) { completion?(configuration) }
}
}
extension TravelAlbumAutoRetouchSettingSheetViewController: UICollectionViewDelegate {
/// 点击模板更新草稿;预览按钮独立处理,不改变选择。
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let template = templateDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.selectTemplate(id: template.id)
}
}
/// 修图方式单选卡片,使用图标、边框和勾选共同传达当前选择。
private final class AutoRetouchModeControl: UIControl {
private let iconContainer = UIView()
private let iconView = UIImageView()
private let checkView = UIImageView()
private let titleLabel = UILabel()
/// 创建可点击的方式卡片;所有内容由整个控件统一响应触摸。
init(title: String, detail: String, symbol: String) {
super.init(frame: .zero)
layer.cornerRadius = 16
layer.borderWidth = 1
iconContainer.layer.cornerRadius = 10
iconView.image = UIImage(systemName: symbol)
iconView.contentMode = .scaleAspectFit
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
let detailLabel = UILabel()
detailLabel.text = detail
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 2
checkView.contentMode = .scaleAspectFit
[iconContainer, iconView, checkView, titleLabel, detailLabel].forEach {
$0.isUserInteractionEnabled = false
addSubview($0)
}
iconContainer.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
make.size.equalTo(32)
}
iconView.snp.makeConstraints { $0.edges.equalTo(iconContainer).inset(7) }
checkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
make.size.equalTo(22)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconContainer.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(12)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(titleLabel)
make.bottom.equalToSuperview().inset(12)
}
isAccessibilityElement = true
accessibilityLabel = "\(title),\(detail)"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// 更新卡片背景与单选语义。
func apply(selected: Bool) {
isSelected = selected
backgroundColor = selected ? AppColor.primaryLight : .white
layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xDFE5EF)).cgColor
layer.borderWidth = selected ? 1.5 : 1
iconContainer.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEFF2F7)
iconView.tintColor = selected ? .white : AppColor.textSecondary
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xC7CFDC)
accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button]
}
}