feat: add AI retouch before-after comparison
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "travel_album_before_after.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 4H20V20H12Z" fill="#000000" fill-opacity="0.32"/>
|
||||
<rect x="4" y="4" width="16" height="16" stroke="#000000" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M12 4V9L15 12L12 15V20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -117,6 +117,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"id": "tpl_refined_12",
|
||||
"name": "清透精修",
|
||||
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
|
||||
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
|
||||
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -135,6 +137,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"id": "tpl_atmosphere_06",
|
||||
"name": "暖阳",
|
||||
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
|
||||
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
|
||||
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -153,6 +157,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"id": "tpl_cover_03",
|
||||
"name": "旅行画册",
|
||||
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
|
||||
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
|
||||
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -178,6 +184,7 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
|
||||
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
||||
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
||||
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
|
||||
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
||||
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
||||
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
||||
|
||||
@@ -403,23 +403,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
||||
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
|
||||
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let previewURL: String
|
||||
let beforeURL: String
|
||||
let afterURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case previewURL = "preview_url"
|
||||
case beforeURL = "before_url"
|
||||
case afterURL = "after_url"
|
||||
}
|
||||
|
||||
/// 创建 AI 修图模板。
|
||||
init(id: Int, name: String, previewURL: String) {
|
||||
init(
|
||||
id: Int,
|
||||
name: String,
|
||||
previewURL: String,
|
||||
beforeURL: String = "",
|
||||
afterURL: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.previewURL = previewURL
|
||||
self.beforeURL = beforeURL
|
||||
self.afterURL = afterURL
|
||||
}
|
||||
|
||||
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(Int.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
previewURL = try container.decode(String.self, forKey: .previewURL)
|
||||
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
|
||||
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
|
||||
struct BeforeAfterComparisonContent: Sendable, Equatable {
|
||||
let beforeURL: String
|
||||
let afterURL: String
|
||||
let beforeLabel: String
|
||||
let afterLabel: String
|
||||
|
||||
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
|
||||
init(
|
||||
beforeURL: String,
|
||||
afterURL: String,
|
||||
beforeLabel: String = "原图",
|
||||
afterLabel: String = "效果图"
|
||||
) {
|
||||
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.beforeLabel = beforeLabel
|
||||
self.afterLabel = afterLabel
|
||||
}
|
||||
|
||||
/// 两张图片地址均存在时才允许进入对比页。
|
||||
var isValid: Bool {
|
||||
!beforeURL.isEmpty && !afterURL.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||
@@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
assets.first { $0.kind == kind }
|
||||
}
|
||||
|
||||
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
|
||||
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
|
||||
guard kind == .retouched || kind == .atmosphere,
|
||||
let original = asset(for: .original),
|
||||
let result = asset(for: kind)
|
||||
else { return nil }
|
||||
let content = BeforeAfterComparisonContent(
|
||||
beforeURL: original.displayURL,
|
||||
afterURL: result.displayURL
|
||||
)
|
||||
return content.isValid ? content : nil
|
||||
}
|
||||
|
||||
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||
init(material: TravelAlbumMaterial) {
|
||||
originalMaterialId = material.id
|
||||
@@ -157,6 +196,14 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplate {
|
||||
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||
var comparisonContent: BeforeAfterComparisonContent? {
|
||||
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||
return content.isValid ? content : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||
struct TravelAlbumPreviewNode: Sendable, Hashable {
|
||||
let projectIndex: Int
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// BeforeAfterComparisonViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 前后对比分隔线的共享运动参数与计算规则。
|
||||
enum BeforeAfterComparisonMotion {
|
||||
static let automaticCenter: CGFloat = 0.5
|
||||
static let automaticAmplitude: CGFloat = 0.3
|
||||
static let automaticSpeed: Double = 0.8
|
||||
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
|
||||
|
||||
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
|
||||
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
|
||||
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
|
||||
}
|
||||
|
||||
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
|
||||
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
|
||||
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
|
||||
}
|
||||
|
||||
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
|
||||
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
|
||||
let normalized = Double(
|
||||
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
|
||||
)
|
||||
return asin(normalized) / automaticSpeed
|
||||
}
|
||||
}
|
||||
|
||||
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
|
||||
enum BeforeAfterComparisonLayout {
|
||||
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
|
||||
|
||||
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
|
||||
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
|
||||
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
|
||||
let ratio = imageSize.width / imageSize.height
|
||||
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
|
||||
}
|
||||
|
||||
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
|
||||
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
|
||||
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
|
||||
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
|
||||
if maximumSize.width / maximumSize.height > ratio {
|
||||
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
|
||||
}
|
||||
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
|
||||
}
|
||||
}
|
||||
|
||||
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
|
||||
final class BeforeAfterComparisonViewModel {
|
||||
let content: BeforeAfterComparisonContent
|
||||
|
||||
/// 创建指定内容的前后对比页状态。
|
||||
init(content: BeforeAfterComparisonContent) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// 原图远程地址。
|
||||
var beforeImageURL: URL? {
|
||||
URL(string: content.beforeURL)
|
||||
}
|
||||
|
||||
/// 效果图远程地址。
|
||||
var afterImageURL: URL? {
|
||||
URL(string: content.afterURL)
|
||||
}
|
||||
|
||||
/// 两张图片地址都可供页面加载时返回 true。
|
||||
var isValid: Bool {
|
||||
content.isValid && beforeImageURL != nil && afterImageURL != nil
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -162,7 +162,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
|
||||
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}],"remaining_quota":12}"#
|
||||
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
@@ -170,6 +170,8 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
let response = try await api.aiRetouchTemplates(scenicId: 18)
|
||||
|
||||
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
|
||||
XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg")
|
||||
XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg")
|
||||
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
|
||||
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
||||
XCTAssertEqual(response.remainingQuota, 12)
|
||||
|
||||
@@ -637,35 +637,96 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
||||
}
|
||||
|
||||
func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws {
|
||||
let template = TravelAlbumAIRetouchTemplate(
|
||||
id: 2,
|
||||
name: "简约留白",
|
||||
previewURL: ""
|
||||
func testBeforeAfterComparisonUsesImmersiveAccessibleLayout() throws {
|
||||
let controller = BeforeAfterComparisonViewController(
|
||||
viewModel: BeforeAfterComparisonViewModel(
|
||||
content: BeforeAfterComparisonContent(
|
||||
beforeURL: "https://cdn.example.com/before.jpg",
|
||||
afterURL: "https://cdn.example.com/after.jpg"
|
||||
)
|
||||
)
|
||||
)
|
||||
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let scrollView = try XCTUnwrap(
|
||||
let imageContainer = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView"
|
||||
} as? UIScrollView
|
||||
$0.accessibilityIdentifier == "travelAlbum.beforeAfterImageContainer"
|
||||
}
|
||||
)
|
||||
let backButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton"
|
||||
$0.accessibilityIdentifier == "travelAlbum.beforeAfterBackButton"
|
||||
} as? UIButton
|
||||
)
|
||||
let divider = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.beforeAfterDivider"
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(controller.view.backgroundColor, UIColor(hex: 0x05070D))
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图" })
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "效果图" })
|
||||
XCTAssertEqual(imageContainer.layer.cornerRadius, 0)
|
||||
XCTAssertTrue(divider.accessibilityTraits.contains(.adjustable))
|
||||
XCTAssertEqual(divider.accessibilityValue, "原图占比 50%")
|
||||
XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
|
||||
XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
|
||||
}
|
||||
|
||||
func testPreviewComparisonButtonOnlyShowsForSelectedAIResult() throws {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
defer { UIView.setAnimationsEnabled(true) }
|
||||
let project = TravelAlbumPreviewProject(
|
||||
originalMaterialId: 7,
|
||||
assets: [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-7",
|
||||
kind: .original,
|
||||
fileURL: "https://cdn.example.com/original.jpg",
|
||||
coverURL: "",
|
||||
fileName: "原图.jpg",
|
||||
fileSize: 10
|
||||
),
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "retouched-7",
|
||||
kind: .retouched,
|
||||
fileURL: "https://cdn.example.com/retouched.jpg",
|
||||
coverURL: "",
|
||||
fileName: "精修.jpg",
|
||||
fileSize: 10
|
||||
),
|
||||
]
|
||||
)
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [project],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
let segmentedControl = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
|
||||
} as? UISegmentedControl
|
||||
)
|
||||
let comparisonButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewComparisonButton"
|
||||
} as? UIButton
|
||||
)
|
||||
|
||||
XCTAssertEqual(controller.view.backgroundColor, .black)
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" })
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" })
|
||||
XCTAssertEqual(scrollView.minimumZoomScale, 1)
|
||||
XCTAssertEqual(scrollView.maximumZoomScale, 4)
|
||||
XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
|
||||
XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
|
||||
XCTAssertTrue(comparisonButton.isHidden)
|
||||
segmentedControl.selectedSegmentIndex = 1
|
||||
segmentedControl.sendActions(for: .valueChanged)
|
||||
|
||||
XCTAssertFalse(comparisonButton.isHidden)
|
||||
XCTAssertNil(comparisonButton.configuration?.title)
|
||||
XCTAssertNotNil(comparisonButton.configuration?.image)
|
||||
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
|
||||
}
|
||||
|
||||
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
|
||||
|
||||
@@ -8,6 +8,40 @@ import XCTest
|
||||
|
||||
/// 旅拍相册模型与展示工具测试。
|
||||
final class TravelAlbumModelsTests: XCTestCase {
|
||||
func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() {
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001)
|
||||
XCTAssertEqual(
|
||||
BeforeAfterComparisonMotion.automaticFraction(
|
||||
elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed)
|
||||
),
|
||||
0.8,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05)
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95)
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42)
|
||||
}
|
||||
|
||||
func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() {
|
||||
let landscape = BeforeAfterComparisonLayout.fittedSize(
|
||||
aspectRatio: 16.0 / 9.0,
|
||||
maximumSize: CGSize(width: 358, height: 580)
|
||||
)
|
||||
let portrait = BeforeAfterComparisonLayout.fittedSize(
|
||||
aspectRatio: 2.0 / 3.0,
|
||||
maximumSize: CGSize(width: 358, height: 580)
|
||||
)
|
||||
|
||||
XCTAssertEqual(landscape.width, 358, accuracy: 0.001)
|
||||
XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001)
|
||||
XCTAssertEqual(portrait.width, 358, accuracy: 0.001)
|
||||
XCTAssertEqual(portrait.height, 537, accuracy: 0.001)
|
||||
XCTAssertEqual(
|
||||
BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)),
|
||||
4.0 / 3.0
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewProjectMapsMaterialToOriginalAsset() {
|
||||
let material = TravelAlbumMaterial(
|
||||
id: 8,
|
||||
@@ -68,6 +102,77 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewProjectBuildsComparisonOnlyForValidAIResults() {
|
||||
let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover])
|
||||
|
||||
XCTAssertEqual(
|
||||
project.comparisonContent(for: .retouched),
|
||||
BeforeAfterComparisonContent(
|
||||
beforeURL: "https://cdn.example.com/8-0.jpg",
|
||||
afterURL: "https://cdn.example.com/8-1.jpg"
|
||||
)
|
||||
)
|
||||
XCTAssertNotNil(project.comparisonContent(for: .atmosphere))
|
||||
XCTAssertNil(project.comparisonContent(for: .original))
|
||||
XCTAssertNil(project.comparisonContent(for: .cover))
|
||||
|
||||
let missingResultURL = TravelAlbumPreviewProject(
|
||||
originalMaterialId: 9,
|
||||
assets: [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-9",
|
||||
kind: .original,
|
||||
fileURL: "https://cdn.example.com/original.jpg",
|
||||
coverURL: "",
|
||||
fileName: "原图.jpg",
|
||||
fileSize: 1
|
||||
),
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "retouched-9",
|
||||
kind: .retouched,
|
||||
fileURL: " ",
|
||||
coverURL: "",
|
||||
fileName: "精修.jpg",
|
||||
fileSize: 1
|
||||
),
|
||||
]
|
||||
)
|
||||
XCTAssertNil(missingResultURL.comparisonContent(for: .retouched))
|
||||
}
|
||||
|
||||
func testTemplateComparisonContentRequiresBothURLs() {
|
||||
let valid = TravelAlbumAIRetouchTemplate(
|
||||
id: 1,
|
||||
name: "清透",
|
||||
previewURL: "https://cdn.example.com/preview.jpg",
|
||||
beforeURL: " https://cdn.example.com/before.jpg ",
|
||||
afterURL: "https://cdn.example.com/after.jpg"
|
||||
)
|
||||
let missingAfter = TravelAlbumAIRetouchTemplate(
|
||||
id: 2,
|
||||
name: "暖阳",
|
||||
previewURL: "https://cdn.example.com/preview.jpg",
|
||||
beforeURL: "https://cdn.example.com/before.jpg"
|
||||
)
|
||||
|
||||
XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg")
|
||||
XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图")
|
||||
XCTAssertNil(missingAfter.comparisonContent)
|
||||
}
|
||||
|
||||
func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws {
|
||||
let data = Data(
|
||||
#"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8
|
||||
)
|
||||
|
||||
let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data)
|
||||
|
||||
XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg")
|
||||
XCTAssertEqual(template.beforeURL, "")
|
||||
XCTAssertEqual(template.afterURL, "")
|
||||
XCTAssertNil(template.comparisonContent)
|
||||
}
|
||||
|
||||
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
|
||||
let missingOriginal = TravelAlbumPreviewAsset(
|
||||
id: "cover-only",
|
||||
|
||||
Reference in New Issue
Block a user