// // 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, 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) } }