feat: add AI retouch before-after comparison
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user