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! 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( 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() 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] } }