feat: add AI retouch before-after comparison

This commit is contained in:
2026-08-13 17:46:06 +08:00
parent e190b5958b
commit 0f3b26991e
12 changed files with 953 additions and 145 deletions
@@ -0,0 +1,520 @@
//
// BeforeAfterComparisonViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// 暗色全屏前后图片对比页,通过可拖拽分隔线查看原图与效果图。
final class BeforeAfterComparisonViewController: UIViewController {
private let viewModel: BeforeAfterComparisonViewModel
private let backButton = UIButton(type: .system)
private let comparisonContainer = UIView()
private let afterImageView = UIImageView()
private let beforeImageView = UIImageView()
private let beforeRevealMask = CAShapeLayer()
private let dividerLine = UIView()
private let dividerHandle = BeforeAfterComparisonHandleView()
private let beforeLabel = BeforeAfterComparisonLabel()
private let afterLabel = BeforeAfterComparisonLabel()
private let statusContainer = UIView()
private let activityIndicator = UIActivityIndicatorView(style: .large)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private var dividerFraction: CGFloat = 0.5
private var loadGeneration = 0
private var beforeLoaded = false
private var afterLoaded = false
private var isReady = false
private var displayLink: CADisplayLink?
private var animationStartTime: CFTimeInterval?
private var isDraggingDivider = false
private var imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
private var beforeImageSize: CGSize?
private var afterImageSize: CGSize?
/// 创建指定前后图片内容的全屏对比页。
init(viewModel: BeforeAfterComparisonViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
bindActions()
loadImages()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutComparisonContainer()
layoutDivider()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startAutomaticMotion(from: dividerFraction)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAutomaticMotion()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
displayLink?.invalidate()
displayLink = nil
}
deinit {
displayLink?.invalidate()
NotificationCenter.default.removeObserver(self)
}
private func setupUI() {
view.backgroundColor = UIColor(hex: 0x05070D)
view.accessibilityIdentifier = "travelAlbum.beforeAfterComparison"
var backConfiguration = UIButton.Configuration.filled()
backConfiguration.image = UIImage(systemName: "chevron.left")
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
pointSize: 19,
weight: .semibold
)
backConfiguration.baseForegroundColor = .white
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
backConfiguration.background.cornerRadius = 24
backConfiguration.contentInsets = .zero
backButton.configuration = backConfiguration
backButton.accessibilityLabel = "返回"
backButton.accessibilityIdentifier = "travelAlbum.beforeAfterBackButton"
comparisonContainer.backgroundColor = UIColor(hex: 0x111827)
comparisonContainer.layer.cornerRadius = 0
comparisonContainer.layer.borderColor = UIColor(hex: 0x263244).cgColor
comparisonContainer.layer.borderWidth = 1
comparisonContainer.clipsToBounds = true
comparisonContainer.accessibilityIdentifier = "travelAlbum.beforeAfterImageContainer"
[afterImageView, beforeImageView].forEach { imageView in
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor(hex: 0x111827)
imageView.isHidden = true
}
afterImageView.accessibilityLabel = viewModel.content.afterLabel
beforeImageView.accessibilityLabel = viewModel.content.beforeLabel
beforeImageView.layer.mask = beforeRevealMask
dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.72)
dividerLine.layer.shadowColor = UIColor.black.cgColor
dividerLine.layer.shadowOpacity = 0.35
dividerLine.layer.shadowRadius = 2
dividerLine.layer.shadowOffset = .zero
dividerLine.isHidden = true
dividerLine.isUserInteractionEnabled = false
dividerHandle.isHidden = true
dividerHandle.accessibilityIdentifier = "travelAlbum.beforeAfterDivider"
beforeLabel.text = viewModel.content.beforeLabel
beforeLabel.accessibilityIdentifier = "travelAlbum.beforeLabel"
afterLabel.text = viewModel.content.afterLabel
afterLabel.accessibilityIdentifier = "travelAlbum.afterLabel"
beforeLabel.isHidden = true
afterLabel.isHidden = true
statusContainer.backgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.86)
activityIndicator.color = .white
activityIndicator.hidesWhenStopped = true
statusLabel.textColor = UIColor.white.withAlphaComponent(0.78)
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
statusLabel.text = "正在加载对比图片"
statusLabel.accessibilityIdentifier = "travelAlbum.beforeAfterStatusLabel"
var retryConfiguration = UIButton.Configuration.filled()
retryConfiguration.title = "重试"
retryConfiguration.baseForegroundColor = .white
retryConfiguration.baseBackgroundColor = UIColor(hex: 0x1677FF)
retryConfiguration.background.cornerRadius = 12
retryButton.configuration = retryConfiguration
retryButton.accessibilityIdentifier = "travelAlbum.beforeAfterRetryButton"
retryButton.isHidden = true
view.addSubview(backButton)
view.addSubview(comparisonContainer)
comparisonContainer.addSubview(afterImageView)
comparisonContainer.addSubview(beforeImageView)
comparisonContainer.addSubview(dividerLine)
comparisonContainer.addSubview(dividerHandle)
comparisonContainer.addSubview(beforeLabel)
comparisonContainer.addSubview(afterLabel)
comparisonContainer.addSubview(statusContainer)
statusContainer.addSubview(activityIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
}
private func setupConstraints() {
backButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.equalToSuperview().offset(18)
make.size.equalTo(48)
}
afterImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
beforeImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
beforeLabel.snp.makeConstraints { make in
make.leading.bottom.equalToSuperview().inset(10)
make.height.equalTo(26)
}
afterLabel.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(10)
make.height.equalTo(26)
}
statusContainer.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-28)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(activityIndicator.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(32)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(14)
make.centerX.equalToSuperview()
make.width.equalTo(96)
make.height.equalTo(44)
}
}
private func bindActions() {
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
let scrubberGesture = UILongPressGestureRecognizer(target: self, action: #selector(dividerDragged(_:)))
scrubberGesture.minimumPressDuration = 0
scrubberGesture.allowableMovement = .greatestFiniteMagnitude
comparisonContainer.addGestureRecognizer(scrubberGesture)
dividerHandle.onIncrement = { [weak self] in self?.adjustDivider(by: 0.1) }
dividerHandle.onDecrement = { [weak self] in self?.adjustDivider(by: -0.1) }
NotificationCenter.default.addObserver(
self,
selector: #selector(reduceMotionStatusChanged),
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil
)
}
private func loadImages() {
loadGeneration += 1
let generation = loadGeneration
beforeImageView.kf.cancelDownloadTask()
afterImageView.kf.cancelDownloadTask()
beforeImageView.image = nil
afterImageView.image = nil
beforeLoaded = false
afterLoaded = false
beforeImageSize = nil
afterImageSize = nil
imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
isReady = false
dividerFraction = BeforeAfterComparisonMotion.automaticCenter
stopAutomaticMotion()
setComparisonVisible(false)
statusContainer.isHidden = false
statusLabel.text = "正在加载对比图片"
retryButton.isHidden = true
activityIndicator.startAnimating()
guard viewModel.isValid,
let beforeURL = viewModel.beforeImageURL,
let afterURL = viewModel.afterImageURL
else {
showLoadFailure()
return
}
beforeImageView.kf.setImage(with: beforeURL) { [weak self] result in
self?.handleImageResult(result, isBefore: true, generation: generation)
}
afterImageView.kf.setImage(with: afterURL) { [weak self] result in
self?.handleImageResult(result, isBefore: false, generation: generation)
}
}
private func handleImageResult(
_ result: Result<RetrieveImageResult, KingfisherError>,
isBefore: Bool,
generation: Int
) {
guard generation == loadGeneration else { return }
switch result {
case let .success(value):
if isBefore {
beforeLoaded = true
beforeImageSize = value.image.size
} else {
afterLoaded = true
afterImageSize = value.image.size
}
guard beforeLoaded && afterLoaded else { return }
let resolvedSize = afterImageSize ?? beforeImageSize ?? .zero
imageAspectRatio = BeforeAfterComparisonLayout.aspectRatio(for: resolvedSize)
view.setNeedsLayout()
view.layoutIfNeeded()
isReady = true
activityIndicator.stopAnimating()
statusContainer.isHidden = true
setComparisonVisible(true)
layoutDivider()
startAutomaticMotion(from: dividerFraction)
UIAccessibility.post(notification: .announcement, argument: "对比图片已加载")
case .failure:
showLoadFailure()
}
}
private func showLoadFailure() {
isReady = false
activityIndicator.stopAnimating()
statusContainer.isHidden = false
statusLabel.text = "图片加载失败,请重试"
retryButton.isHidden = false
setComparisonVisible(false)
UIAccessibility.post(notification: .announcement, argument: statusLabel.text)
}
private func setComparisonVisible(_ visible: Bool) {
afterImageView.isHidden = !visible
beforeImageView.isHidden = !visible
dividerLine.isHidden = !visible
dividerHandle.isHidden = !visible
beforeLabel.isHidden = !visible
afterLabel.isHidden = !visible
}
private func layoutComparisonContainer() {
let safeFrame = view.bounds.inset(by: view.safeAreaInsets)
let topBoundary = max(safeFrame.minY, backButton.frame.maxY + 24)
let bottomBoundary = safeFrame.maxY - 24
let center = CGPoint(x: view.bounds.midX, y: safeFrame.midY)
let verticalRadius = max(
1,
min(center.y - topBoundary, bottomBoundary - center.y)
)
let maximumSize = CGSize(
width: max(1, view.bounds.width),
height: verticalRadius * 2
)
let fittedSize = BeforeAfterComparisonLayout.fittedSize(
aspectRatio: imageAspectRatio,
maximumSize: maximumSize
)
comparisonContainer.frame = CGRect(
x: center.x - fittedSize.width / 2,
y: center.y - fittedSize.height / 2,
width: fittedSize.width,
height: fittedSize.height
)
comparisonContainer.layoutIfNeeded()
}
private func layoutDivider() {
guard comparisonContainer.bounds.width > 0 else { return }
let bounds = comparisonContainer.bounds
let x = bounds.width * dividerFraction
beforeRevealMask.frame = beforeImageView.bounds
beforeRevealMask.path = UIBezierPath(
rect: CGRect(x: 0, y: 0, width: x, height: bounds.height)
).cgPath
dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
dividerHandle.accessibilityValue = "原图占比 (Int((dividerFraction * 100).rounded()))%"
}
private func updateDivider(locationX: CGFloat) {
guard isReady, comparisonContainer.bounds.width > 0 else { return }
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(
locationX / comparisonContainer.bounds.width
)
layoutDivider()
}
private func adjustDivider(by delta: CGFloat) {
stopAutomaticMotion()
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(dividerFraction + delta)
layoutDivider()
}
@objc private func dividerDragged(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
isDraggingDivider = true
stopAutomaticMotion()
dividerHandle.setActive(true, animated: !UIAccessibility.isReduceMotionEnabled)
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
case .changed:
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
case .ended, .cancelled, .failed:
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
isDraggingDivider = false
dividerHandle.setActive(false, animated: !UIAccessibility.isReduceMotionEnabled)
startAutomaticMotion(from: dividerFraction)
default:
break
}
}
@objc private func automaticMotionTick(_ link: CADisplayLink) {
guard isReady, !isDraggingDivider, let animationStartTime else { return }
dividerFraction = BeforeAfterComparisonMotion.automaticFraction(
elapsed: link.timestamp - animationStartTime
)
layoutDivider()
}
private func startAutomaticMotion(from fraction: CGFloat) {
guard isReady,
viewIfLoaded?.window != nil,
!isDraggingDivider,
!UIAccessibility.isReduceMotionEnabled
else { return }
animationStartTime = CACurrentMediaTime() - BeforeAfterComparisonMotion.phaseTime(for: fraction)
if displayLink == nil {
let link = CADisplayLink(target: self, selector: #selector(automaticMotionTick(_:)))
link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
link.add(to: .main, forMode: .common)
displayLink = link
}
displayLink?.isPaused = false
}
private func stopAutomaticMotion() {
displayLink?.isPaused = true
animationStartTime = nil
}
@objc private func reduceMotionStatusChanged() {
if UIAccessibility.isReduceMotionEnabled {
stopAutomaticMotion()
} else {
startAutomaticMotion(from: dividerFraction)
}
}
@objc private func backTapped() {
dismiss(animated: true)
}
@objc private func retryTapped() {
loadImages()
}
}
/// 前后对比分隔线的可调节圆形手柄。
private final class BeforeAfterComparisonHandleView: UIView {
var onIncrement: (() -> Void)?
var onDecrement: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.38)
layer.cornerRadius = 24
layer.borderColor = UIColor.white.withAlphaComponent(0.82).cgColor
layer.borderWidth = 2
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.32
layer.shadowRadius = 7
layer.shadowOffset = CGSize(width: 0, height: 3)
isAccessibilityElement = true
accessibilityLabel = "前后对比分隔线"
accessibilityTraits = [.adjustable]
let imageView = UIImageView(image: UIImage(systemName: "arrow.left.and.right"))
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
addSubview(imageView)
imageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(21)
}
}
/// 更新手柄按压态,为拖动接管提供轻微但不改变布局的视觉反馈。
func setActive(_ active: Bool, animated: Bool) {
let changes = {
self.transform = active ? CGAffineTransform(scaleX: 1.08, y: 1.08) : .identity
self.backgroundColor = UIColor.black.withAlphaComponent(active ? 0.52 : 0.38)
}
guard animated else {
changes()
return
}
UIView.animate(
withDuration: 0.18,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState],
animations: changes
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func accessibilityIncrement() {
onIncrement?()
}
override func accessibilityDecrement() {
onDecrement?()
}
}
/// 前后对比图片底部的高对比度胶囊角标。
private final class BeforeAfterComparisonLabel: UILabel {
override init(frame: CGRect) {
super.init(frame: frame)
textColor = .white
font = .systemFont(ofSize: 12, weight: .medium)
textAlignment = .center
backgroundColor = UIColor.black.withAlphaComponent(0.5)
layer.cornerRadius = 13
layer.borderColor = UIColor.white.withAlphaComponent(0.2).cgColor
layer.borderWidth = 0.5
clipsToBounds = true
setContentHuggingPriority(.required, for: .horizontal)
layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + 20, height: 26)
}
}
@@ -434,8 +434,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
}
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
controller.modalPresentationStyle = .fullScreen
guard let content = template.comparisonContent else {
showToast("暂无对比预览")
return
}
let controller = BeforeAfterComparisonViewController(
viewModel: BeforeAfterComparisonViewModel(content: content)
)
present(controller, animated: true)
}
}
@@ -908,128 +913,6 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
}
}
/// AI 修图模板全屏预览,支持双指缩放查看模板细节。
final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController {
private let template: TravelAlbumAIRetouchTemplate
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let scrollView = UIScrollView()
private let imageView = UIImageView()
/// 创建指定模板的全屏预览页。
init(template: TravelAlbumAIRetouchTemplate) {
self.template = template
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
loadPreview()
}
private func setupUI() {
view.backgroundColor = .black
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview"
var backConfiguration = UIButton.Configuration.filled()
backConfiguration.image = UIImage(systemName: "chevron.left")
backConfiguration.baseForegroundColor = .white
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
backConfiguration.background.cornerRadius = 24
backButton.configuration = backConfiguration
backButton.accessibilityLabel = "返回模板选择"
backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
titleLabel.text = template.name
titleLabel.textColor = .white
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byTruncatingTail
subtitleLabel.text = "双指缩放查看细节"
subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52)
subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
subtitleLabel.textAlignment = .center
scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 4
scrollView.delegate = self
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView"
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor(hex: 0x111111)
imageView.accessibilityLabel = "\(template.name)模板预览图"
view.addSubview(backButton)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(scrollView)
scrollView.addSubview(imageView)
}
private func setupConstraints() {
backButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.equalToSuperview().offset(18)
make.size.equalTo(48)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
make.centerX.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview().offset(-66)
make.height.equalTo(26)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.centerX.equalToSuperview()
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
make.leading.trailing.bottom.equalToSuperview()
}
imageView.snp.makeConstraints { make in
make.center.equalTo(scrollView.frameLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
make.height.equalTo(imageView.snp.width).multipliedBy(0.75)
}
}
private func loadPreview() {
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
imageView.image = placeholder
return
}
imageView.kf.setImage(
with: url,
placeholder: placeholder
)
}
@objc private func backTapped() {
dismiss(animated: true)
}
}
extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
}
/// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF)
@@ -48,6 +48,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let variantControls = UIView()
private let variantSegmentedControl = UISegmentedControl()
private let highResolutionButton = UIButton(type: .system)
private let comparisonButton = UIButton(type: .system)
private let actionStack = UIStackView()
private let deleteButton = UIButton(type: .system)
private let refreshButton = UIButton(type: .system)
@@ -193,6 +194,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
)
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
configureHighResolutionButton()
configureComparisonButton()
configureActions()
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
@@ -212,6 +214,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
variantControls.addSubview(variantSegmentedControl)
variantControls.addSubview(highResolutionButton)
bottomChrome.addSubview(actionStack)
view.addSubview(comparisonButton)
topChrome.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
@@ -274,6 +277,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
make.height.equalTo(70)
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
comparisonButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomChrome.snp.top).offset(-12)
make.size.equalTo(48)
}
}
private func configureHighResolutionButton() {
@@ -293,6 +301,27 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
}
private func configureComparisonButton() {
var configuration = UIButton.Configuration.filled()
configuration.image = UIImage(named: "travel_album_before_after")?.withRenderingMode(.alwaysTemplate)
configuration.contentInsets = .zero
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.94)
configuration.background.cornerRadius = 24
configuration.background.strokeColor = UIColor(hex: 0x263244)
configuration.background.strokeWidth = 1
comparisonButton.configuration = configuration
comparisonButton.accessibilityLabel = "前后对比"
comparisonButton.accessibilityHint = "对比当前效果图与原图"
comparisonButton.accessibilityIdentifier = "travelAlbum.previewComparisonButton"
comparisonButton.layer.shadowColor = UIColor.black.cgColor
comparisonButton.layer.shadowOpacity = 0.28
comparisonButton.layer.shadowRadius = 8
comparisonButton.layer.shadowOffset = CGSize(width: 0, height: 3)
comparisonButton.isHidden = true
comparisonButton.addTarget(self, action: #selector(comparisonTapped), for: .touchUpInside)
}
private func configureActions() {
let aiButton = makeActionButton(
title: "AI修图",
@@ -392,9 +421,21 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
highResolutionButton.isEnabled = asset != nil
highResolutionButton.alpha = asset == nil ? 0.45 : 1
rebuildTabs()
updateComparisonButton()
loadMoreIfNeeded(projectIndex: node.projectIndex)
}
private func updateComparisonButton() {
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
let shouldShow = chromeVisible && hasComparison
comparisonButton.isHidden = !hasComparison
comparisonButton.isUserInteractionEnabled = shouldShow
comparisonButton.alpha = shouldShow ? 1 : 0
comparisonButton.transform = shouldShow
? .identity
: CGAffineTransform(translationX: 0, y: 10)
}
private func rebuildTabs() {
while variantSegmentedControl.numberOfSegments > 0 {
variantSegmentedControl.removeSegment(at: 0, animated: false)
@@ -581,6 +622,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private func toggleChrome() {
chromeVisible.toggle()
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
if chromeVisible && hasComparison {
comparisonButton.isHidden = false
}
comparisonButton.isUserInteractionEnabled = chromeVisible && hasComparison
let reduceMotion = UIAccessibility.isReduceMotionEnabled
let animations = {
self.topChrome.alpha = self.chromeVisible ? 1 : 0
@@ -591,12 +637,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
self.bottomChrome.transform = self.chromeVisible
? .identity
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
self.comparisonButton.alpha = self.chromeVisible && hasComparison ? 1 : 0
self.comparisonButton.transform = self.chromeVisible && hasComparison
? .identity
: CGAffineTransform(translationX: 0, y: 10)
}
UIView.animate(
withDuration: reduceMotion ? 0.12 : 0.22,
delay: 0,
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
animations: animations
animations: animations,
completion: { [weak self] _ in
guard let self else { return }
self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison
}
)
}
@@ -632,6 +686,17 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
toggleChrome()
}
@objc private func comparisonTapped() {
guard presentedViewController == nil,
let project = currentProject,
let content = project.comparisonContent(for: currentAsset?.kind ?? .original)
else { return }
let controller = BeforeAfterComparisonViewController(
viewModel: BeforeAfterComparisonViewModel(content: content)
)
present(controller, animated: true)
}
@objc private func aiTapped() {
guard presentedViewController == nil else { return }
guard let project = currentProject else { return }