417 lines
17 KiB
Swift
417 lines
17 KiB
Swift
//
|
||
// 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 "高饱和浓郁油画质感"
|
||
}
|
||
}
|
||
}
|