Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift

417 lines
17 KiB
Swift
Raw Permalink 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.
//
// TravelAlbumAutoRetouchSettingSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 自动 AI 修图设置 Sheet,先选修图方式,选择 AI 修图后再选带效果图的模板。
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController, UITableViewDelegate {
private enum Step: Equatable {
case mode
case template
}
private enum Item: Hashable {
case mode(TravelAlbumRetouchMode)
case template(TravelAlbumEditPreset)
}
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
var onCancelled: (() -> Void)?
private let startsWithModeSelection: Bool
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
private var step: Step
private var pendingConfiguration: TravelAlbumAutoRetouchConfiguration
private var selectedItem: Item?
private var previewImages: [TravelAlbumEditPreset.Effect: UIImage] = [:]
private var didConfirm = false
/// 创建自动修图设置 Sheet。
/// - Parameters:
/// - configuration: 当前配置,用于回显已选方式和模板。
/// - startsWithModeSelection: 是否先展示“不修图 / AI 修图”两个一级选项。
init(
configuration: TravelAlbumAutoRetouchConfiguration,
startsWithModeSelection: Bool
) {
self.startsWithModeSelection = startsWithModeSelection
step = startsWithModeSelection ? .mode : .template
pendingConfiguration = configuration
if startsWithModeSelection {
selectedItem = .mode(configuration.isEnabled ? .aiRetouch : .disabled)
} else if let template = configuration.template {
selectedItem = .template(template)
}
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumAutoRetouchSetting")
sheetPresentationController.detents = [
.custom(identifier: identifier) { context in
min(660, context.maximumDetentValue)
},
.large(),
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = AppRadius.xl
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {}
override func setupUI() {
view.backgroundColor = .white
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.textPrimary
backButton.accessibilityLabel = "返回修图方式"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.font = .app(.caption)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
subtitleLabel.numberOfLines = 0
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
tableView.delegate = self
tableView.register(
TravelAlbumAutoRetouchOptionCell.self,
forCellReuseIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier
)
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) {
[weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAutoRetouchOptionCell
guard let self else { return cell }
switch item {
case .mode(let mode):
let iconName = mode == .disabled ? "photo" : "wand.and.stars"
let detail = mode == .disabled
? "保留照片原效果"
: (self.pendingConfiguration.template.map { "当前模板:\($0.title)" } ?? "选择后需再选一个修图模板")
cell.apply(
title: mode.title,
detail: detail,
previewImage: UIImage(systemName: iconName),
usesTemplateImage: true,
selected: self.selectedItem == item
)
case .template(let preset):
cell.apply(
title: preset.title,
detail: preset.autoRetouchEffectDescription,
previewImage: self.previewImage(for: preset),
usesTemplateImage: false,
selected: self.selectedItem == item
)
}
return cell
}
configureActionButton(cancelButton, title: "取消", isPrimary: false)
configureActionButton(confirmButton, title: "确定", isPrimary: true)
let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
buttonStack.axis = .horizontal
buttonStack.spacing = AppSpacing.sm
buttonStack.distribution = .fillEqually
view.addSubview(backButton)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(tableView)
view.addSubview(buttonStack)
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.md)
make.centerY.equalTo(titleLabel)
make.size.equalTo(AppSpacing.minTouchTarget)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.lg)
make.leading.trailing.equalToSuperview().inset(60)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(buttonStack.snp.top).offset(-AppSpacing.sm)
}
buttonStack.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-AppSpacing.md)
make.height.equalTo(AppSpacing.minTouchTarget)
}
refreshContent()
}
override func bindActions() {
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed, !didConfirm {
onCancelled?()
}
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
step == .mode ? 80 : 108
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .mode(.disabled):
pendingConfiguration = .disabled
selectedItem = item
applySnapshot()
updateConfirmButton()
case .mode(.aiRetouch):
step = .template
selectedItem = pendingConfiguration.template.map(Item.template)
refreshContent()
case .template(let preset):
pendingConfiguration = .enabled(templateID: preset.id)
selectedItem = item
applySnapshot()
updateConfirmButton()
}
}
private func refreshContent() {
switch step {
case .mode:
titleLabel.text = "选择修图方式"
subtitleLabel.text = "照片上传前可选择保留原图,或使用 AI 自动修图"
backButton.isHidden = true
case .template:
titleLabel.text = "选择修图模板"
subtitleLabel.text = "缩略图为模板实际效果,后续上传的照片将自动套用"
backButton.isHidden = !startsWithModeSelection
}
applySnapshot()
updateConfirmButton()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0])
switch step {
case .mode:
snapshot.appendItems(TravelAlbumRetouchMode.allCases.map(Item.mode))
case .template:
snapshot.appendItems(TravelAlbumEditPreset.autoRetouchOptions.map(Item.template))
}
// 选中状态存放在页面状态中,item 本身的标识不会变化。
// Diffable Data Source 不会主动重新配置相同 item,因此需显式刷新仍在当前列表中的选项。
let currentItems = Set(dataSource.snapshot().itemIdentifiers)
let retainedItems = snapshot.itemIdentifiers.filter(currentItems.contains)
snapshot.reconfigureItems(retainedItems)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func updateConfirmButton() {
let isEnabled: Bool
switch selectedItem {
case .mode(.disabled):
isEnabled = step == .mode
case .mode(.aiRetouch):
isEnabled = step == .mode && pendingConfiguration.isEnabled && pendingConfiguration.isValid
case .template:
isEnabled = step == .template && pendingConfiguration.isEnabled && pendingConfiguration.isValid
case nil:
isEnabled = false
}
confirmButton.isEnabled = isEnabled
confirmButton.alpha = isEnabled ? 1 : 0.45
}
private func configureActionButton(_ button: UIButton, title: String, isPrimary: Bool) {
button.setTitle(title, for: .normal)
button.setTitleColor(isPrimary ? .white : AppColor.textSecondary, for: .normal)
button.titleLabel?.font = .app(.subtitle)
button.backgroundColor = isPrimary ? AppColor.primary : AppColor.inputBackground
button.layer.cornerRadius = AppRadius.md
}
private func previewImage(for preset: TravelAlbumEditPreset) -> UIImage? {
if let image = previewImages[preset.effect] {
return image
}
guard let source = makePreviewSourceImage() else { return nil }
let image = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: source)
previewImages[preset.effect] = image
return image
}
private func makePreviewSourceImage() -> UIImage? {
guard let source = UIImage(named: "purchased_lakeside_flowers") else { return nil }
let size = CGSize(width: 176, height: 176)
return UIGraphicsImageRenderer(size: size).image { _ in
let scale = max(size.width / source.size.width, size.height / source.size.height)
let drawSize = CGSize(width: source.size.width * scale, height: source.size.height * scale)
let origin = CGPoint(x: (size.width - drawSize.width) / 2, y: (size.height - drawSize.height) / 2)
source.draw(in: CGRect(origin: origin, size: drawSize))
}
}
@objc private func backTapped() {
guard startsWithModeSelection else { return }
step = .mode
selectedItem = .mode(pendingConfiguration.isEnabled ? .aiRetouch : .disabled)
refreshContent()
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard confirmButton.isEnabled else { return }
didConfirm = true
onConfirm?(pendingConfiguration)
dismiss(animated: true)
}
}
/// 自动修图选项行,一级方式显示图标,模板显示本地生成的真实效果缩略图。
private final class TravelAlbumAutoRetouchOptionCell: UITableViewCell {
static let reuseIdentifier = "TravelAlbumAutoRetouchOptionCell"
private let cardView = UIView()
private let previewImageView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let selectionImageView = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
cardView.layer.cornerRadius = AppRadius.sm
cardView.layer.borderWidth = 1
previewImageView.clipsToBounds = true
previewImageView.layer.cornerRadius = AppRadius.xs
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .app(.caption)
detailLabel.textColor = AppColor.textTertiary
detailLabel.numberOfLines = 2
selectionImageView.contentMode = .scaleAspectFit
contentView.addSubview(cardView)
cardView.addSubview(previewImageView)
cardView.addSubview(titleLabel)
cardView.addSubview(detailLabel)
cardView.addSubview(selectionImageView)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
previewImageView.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview().inset(8)
make.width.equalTo(88)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(previewImageView.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-AppSpacing.sm)
make.bottom.equalTo(cardView.snp.centerY).offset(-2)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(cardView.snp.centerY).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
selectionImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-AppSpacing.md)
make.centerY.equalToSuperview()
make.size.equalTo(22)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 刷新选项文案、效果图与单选外观。
func apply(
title: String,
detail: String,
previewImage: UIImage?,
usesTemplateImage: Bool,
selected: Bool
) {
titleLabel.text = title
detailLabel.text = detail
previewImageView.image = usesTemplateImage
? previewImage?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 28, weight: .medium))
: previewImage
previewImageView.contentMode = usesTemplateImage ? .center : .scaleAspectFill
previewImageView.tintColor = AppColor.primary
previewImageView.backgroundColor = usesTemplateImage ? AppColor.primaryLight : AppColor.pageBackground
cardView.backgroundColor = selected ? AppColor.primaryLight : .white
cardView.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
accessibilityLabel = "\(title),\(detail)"
accessibilityValue = selected ? "已选择" : "未选择"
}
}
private extension TravelAlbumEditPreset {
/// 模板效果的简短说明,与缩略图一起帮助用户判断效果。
var autoRetouchEffectDescription: String {
switch effect {
case .original:
return "保留原图色彩"
case .portrait:
return "明亮柔和的人像质感"
case .vintage:
return "低饱和复古色调"
case .brocade:
return "鲜活通透的旅拍风格"
case .distantMountain:
return "自然淡雅的远山色调"
case .mist:
return "轻雾低对比氛围"
case .summer:
return "温暖明亮的油画色彩"
case .rich:
return "高饱和浓郁油画质感"
}
}
}