// // 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! 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(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() 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 "高饱和浓郁油画质感" } } }