fix: 优化任务详情与图片预览布局
This commit is contained in:
@@ -533,7 +533,7 @@ private final class AIJobDetailAlbumCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。
|
/// 任务内容卡,以三色紧凑标签展示各输出目标数量并补充额度结算。
|
||||||
private final class AIJobDetailContentCard: UIView {
|
private final class AIJobDetailContentCard: UIView {
|
||||||
private let outputStack = UIStackView()
|
private let outputStack = UIStackView()
|
||||||
private let quotaLabel = UILabel()
|
private let quotaLabel = UILabel()
|
||||||
@@ -548,7 +548,8 @@ private final class AIJobDetailContentCard: UIView {
|
|||||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
outputStack.axis = .horizontal
|
outputStack.axis = .horizontal
|
||||||
outputStack.spacing = 10
|
outputStack.spacing = 10
|
||||||
outputStack.distribution = .fillEqually
|
outputStack.distribution = .fill
|
||||||
|
outputStack.alignment = .center
|
||||||
quotaLabel.font = .systemFont(ofSize: 12)
|
quotaLabel.font = .systemFont(ofSize: 12)
|
||||||
quotaLabel.textColor = AIJobDetailStyle.textSecondary
|
quotaLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
quotaLabel.numberOfLines = 0
|
quotaLabel.numberOfLines = 0
|
||||||
@@ -565,6 +566,12 @@ private final class AIJobDetailContentCard: UIView {
|
|||||||
func apply(_ detail: TravelAlbumAIJobDetail) {
|
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||||
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
|
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
|
||||||
|
if let lastChip = outputStack.arrangedSubviews.last {
|
||||||
|
let spacer = UIView()
|
||||||
|
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
outputStack.setCustomSpacing(0, after: lastChip)
|
||||||
|
outputStack.addArrangedSubview(spacer)
|
||||||
|
}
|
||||||
outputStack.isHidden = detail.outputs.isEmpty
|
outputStack.isHidden = detail.outputs.isEmpty
|
||||||
let quota = detail.quotaSettlement
|
let quota = detail.quotaSettlement
|
||||||
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
|
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
|
||||||
@@ -573,32 +580,42 @@ private final class AIJobDetailContentCard: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
|
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
|
||||||
let container = UIView()
|
AIJobDetailOutputChip(
|
||||||
container.backgroundColor = AppColor.primaryLight
|
text: "\(output.type.shortTitle) \(output.count)张",
|
||||||
container.layer.cornerRadius = 9
|
backgroundColor: output.type.chipBackgroundColor,
|
||||||
let iconView = UIImageView(image: UIImage(systemName: output.type.symbolName))
|
accessibilityIdentifier: "aiRetouchJob.contentChip.\(output.type.chipIdentifier)"
|
||||||
iconView.tintColor = AppColor.primary
|
)
|
||||||
iconView.contentMode = .scaleAspectFit
|
}
|
||||||
let label = UILabel()
|
}
|
||||||
label.text = "\(output.type.shortTitle) \(output.count) 张"
|
|
||||||
|
/// 依据文字固有宽度展示的任务输出类型标签。
|
||||||
|
private final class AIJobDetailOutputChip: UIView {
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
init(text: String, backgroundColor: UIColor, accessibilityIdentifier: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.accessibilityIdentifier = accessibilityIdentifier
|
||||||
|
layer.cornerRadius = 9
|
||||||
|
setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
label.text = text
|
||||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
label.textColor = AIJobDetailStyle.textPrimary
|
label.textColor = AIJobDetailStyle.textPrimary
|
||||||
label.adjustsFontSizeToFitWidth = true
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
label.minimumScaleFactor = 0.75
|
addSubview(label)
|
||||||
container.addSubview(iconView)
|
|
||||||
container.addSubview(label)
|
|
||||||
iconView.snp.makeConstraints { make in
|
|
||||||
make.leading.equalToSuperview().offset(10)
|
|
||||||
make.centerY.equalToSuperview()
|
|
||||||
make.size.equalTo(18)
|
|
||||||
}
|
|
||||||
label.snp.makeConstraints { make in
|
label.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(iconView.snp.trailing).offset(6)
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
make.trailing.equalToSuperview().inset(8)
|
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
}
|
}
|
||||||
container.snp.makeConstraints { $0.height.equalTo(44) }
|
snp.makeConstraints { $0.height.equalTo(44) }
|
||||||
return container
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: ceil(label.intrinsicContentSize.width) + 24, height: 44)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,6 +680,9 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
private let templateLabel = UILabel()
|
private let templateLabel = UILabel()
|
||||||
private let statusIconView = UIImageView()
|
private let statusIconView = UIImageView()
|
||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
|
private let statusStack = UIStackView()
|
||||||
|
private let statusRow = UIStackView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
private let resultButton = UIButton(type: .system)
|
private let resultButton = UIButton(type: .system)
|
||||||
private let errorContainer = UIView()
|
private let errorContainer = UIView()
|
||||||
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||||
@@ -679,8 +699,11 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
templateLabel.font = .systemFont(ofSize: 13)
|
templateLabel.font = .systemFont(ofSize: 13)
|
||||||
templateLabel.textColor = AIJobDetailStyle.textSecondary
|
templateLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
templateLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
templateLabel.numberOfLines = 1
|
||||||
statusIconView.contentMode = .scaleAspectFit
|
statusIconView.contentMode = .scaleAspectFit
|
||||||
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
resultButton.setTitle("查看结果", for: .normal)
|
resultButton.setTitle("查看结果", for: .normal)
|
||||||
@@ -693,49 +716,45 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
errorLabel.textColor = AppColor.danger
|
errorLabel.textColor = AppColor.danger
|
||||||
errorLabel.numberOfLines = 0
|
errorLabel.numberOfLines = 0
|
||||||
|
|
||||||
let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel])
|
statusStack.addArrangedSubview(statusIconView)
|
||||||
|
statusStack.addArrangedSubview(statusLabel)
|
||||||
statusStack.axis = .horizontal
|
statusStack.axis = .horizontal
|
||||||
statusStack.spacing = 6
|
statusStack.spacing = 6
|
||||||
statusStack.alignment = .center
|
statusStack.alignment = .center
|
||||||
|
statusStack.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
let statusSpacer = UIView()
|
||||||
|
statusRow.addArrangedSubview(statusStack)
|
||||||
|
statusRow.addArrangedSubview(statusSpacer)
|
||||||
|
statusRow.addArrangedSubview(resultButton)
|
||||||
|
statusRow.axis = .horizontal
|
||||||
|
statusRow.spacing = 8
|
||||||
|
statusRow.alignment = .center
|
||||||
|
contentStack.addArrangedSubview(titleLabel)
|
||||||
|
contentStack.addArrangedSubview(templateLabel)
|
||||||
|
contentStack.addArrangedSubview(statusRow)
|
||||||
|
contentStack.addArrangedSubview(errorContainer)
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 0
|
||||||
|
contentStack.setCustomSpacing(7, after: titleLabel)
|
||||||
|
contentStack.setCustomSpacing(8, after: templateLabel)
|
||||||
|
contentStack.setCustomSpacing(12, after: statusRow)
|
||||||
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
|
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
|
||||||
addSubview(thumbnailView)
|
addSubview(thumbnailView)
|
||||||
addSubview(titleLabel)
|
addSubview(contentStack)
|
||||||
addSubview(templateLabel)
|
|
||||||
addSubview(statusStack)
|
|
||||||
addSubview(resultButton)
|
|
||||||
addSubview(errorContainer)
|
|
||||||
errorContainer.addSubview(errorIconView)
|
errorContainer.addSubview(errorIconView)
|
||||||
errorContainer.addSubview(errorLabel)
|
errorContainer.addSubview(errorLabel)
|
||||||
|
|
||||||
thumbnailView.snp.makeConstraints { make in
|
thumbnailView.snp.makeConstraints { make in
|
||||||
make.leading.top.equalToSuperview().offset(4)
|
make.leading.top.equalToSuperview().offset(4)
|
||||||
make.size.equalTo(80)
|
make.size.equalTo(80)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().inset(12)
|
||||||
}
|
}
|
||||||
titleLabel.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
|
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
|
||||||
make.top.equalTo(thumbnailView).offset(18)
|
make.top.equalTo(thumbnailView).offset(18)
|
||||||
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
|
|
||||||
}
|
|
||||||
templateLabel.snp.makeConstraints { make in
|
|
||||||
make.leading.equalTo(titleLabel)
|
|
||||||
make.top.equalTo(titleLabel.snp.bottom).offset(7)
|
|
||||||
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
|
|
||||||
}
|
|
||||||
statusStack.snp.makeConstraints { make in
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
|
||||||
make.centerY.equalTo(thumbnailView).offset(-7)
|
|
||||||
}
|
|
||||||
resultButton.snp.makeConstraints { make in
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
|
||||||
make.top.equalTo(statusStack.snp.bottom).offset(4)
|
|
||||||
make.height.equalTo(28)
|
|
||||||
}
|
|
||||||
errorContainer.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
|
||||||
make.leading.equalTo(titleLabel)
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
make.trailing.equalToSuperview().inset(4)
|
||||||
make.bottom.equalToSuperview().inset(12)
|
make.bottom.equalToSuperview().inset(12)
|
||||||
make.height.greaterThanOrEqualTo(38)
|
|
||||||
}
|
}
|
||||||
errorIconView.snp.makeConstraints { make in
|
errorIconView.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(10)
|
make.leading.equalToSuperview().offset(10)
|
||||||
@@ -780,21 +799,9 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
|
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
|
||||||
let showsError = target.displayFailureMessage != nil
|
let showsError = target.displayFailureMessage != nil
|
||||||
errorContainer.isHidden = !showsError
|
errorContainer.isHidden = !showsError
|
||||||
errorContainer.snp.remakeConstraints { make in
|
titleLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).title"
|
||||||
if showsError {
|
templateLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).template"
|
||||||
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
statusStack.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).status"
|
||||||
} else {
|
|
||||||
make.top.equalTo(thumbnailView.snp.bottom)
|
|
||||||
}
|
|
||||||
make.leading.equalTo(titleLabel)
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
|
||||||
make.bottom.equalToSuperview().inset(12)
|
|
||||||
if showsError {
|
|
||||||
make.height.greaterThanOrEqualTo(38)
|
|
||||||
} else {
|
|
||||||
make.height.equalTo(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
|
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
|
||||||
.compactMap { $0 }.joined(separator: ",")
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
}
|
}
|
||||||
@@ -896,12 +903,21 @@ private extension TravelAlbumAIJobOutputType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var symbolName: String {
|
var chipBackgroundColor: UIColor {
|
||||||
switch self {
|
switch self {
|
||||||
case .refined: "wand.and.stars"
|
case .refined: UIColor(hex: 0xEAF3FF)
|
||||||
case .atmosphere: "sun.max"
|
case .atmosphere: UIColor(hex: 0xFFF2DC)
|
||||||
case .cover: "bookmark"
|
case .cover: UIColor(hex: 0xF2ECFF)
|
||||||
case .unknown: "sparkles"
|
case .unknown: UIColor(hex: 0xF1F5F9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipIdentifier: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "refined"
|
||||||
|
case .atmosphere: "atmosphere"
|
||||||
|
case .cover: "cover"
|
||||||
|
case .unknown: "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
self.allowsActions = allowsActions
|
self.allowsActions = allowsActions
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .fullScreen
|
modalPresentationStyle = .fullScreen
|
||||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind)
|
let resolvedProjectIndex = max(0, min(startProjectIndex, self.projects.count - 1))
|
||||||
|
let initialProject = self.projects.indices.contains(resolvedProjectIndex)
|
||||||
|
? self.projects[resolvedProjectIndex]
|
||||||
|
: nil
|
||||||
|
let resolvedStartKind = initialProject?.asset(for: startKind) == nil
|
||||||
|
? initialProject?.orderedAssets.first?.kind ?? .original
|
||||||
|
: startKind
|
||||||
|
selectedKind = resolvedStartKind
|
||||||
|
rebuildNodes(keepingProjectIndex: resolvedProjectIndex, kind: resolvedStartKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -165,6 +173,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||||
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||||
sizeLabel.textAlignment = .center
|
sizeLabel.textAlignment = .center
|
||||||
|
sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel"
|
||||||
|
|
||||||
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||||
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
||||||
@@ -423,7 +432,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
guard let node = currentNode else { return }
|
guard let node = currentNode else { return }
|
||||||
let asset = currentAsset
|
let asset = currentAsset
|
||||||
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
|
let fileSize = asset?.fileSize ?? 0
|
||||||
|
sizeLabel.text = fileSize > 0 ? TravelAlbumDisplayFormatter.fileSizeText(fileSize) : nil
|
||||||
|
sizeLabel.isHidden = fileSize <= 0
|
||||||
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
||||||
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
||||||
highResolutionButton.isEnabled = asset != nil
|
highResolutionButton.isEnabled = asset != nil
|
||||||
@@ -982,6 +993,56 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 深色图片预览中使用的高对比度延迟加载指示器。
|
||||||
|
@MainActor
|
||||||
|
private final class TravelAlbumPreviewLoadingIndicator: Indicator {
|
||||||
|
private let containerView = UIView()
|
||||||
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var revealTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var view: IndicatorView { containerView }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
containerView.backgroundColor = UIColor(red: 39 / 255, green: 39 / 255, blue: 42 / 255, alpha: 0.94)
|
||||||
|
containerView.layer.cornerRadius = 22
|
||||||
|
containerView.isAccessibilityElement = true
|
||||||
|
containerView.accessibilityIdentifier = "travelAlbum.previewLoadingIndicator"
|
||||||
|
containerView.accessibilityLabel = "图片加载中"
|
||||||
|
activityIndicator.color = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
activityIndicator.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||||
|
containerView.addSubview(activityIndicator)
|
||||||
|
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAnimatingView() {
|
||||||
|
revealTask?.cancel()
|
||||||
|
containerView.alpha = 0
|
||||||
|
containerView.isHidden = true
|
||||||
|
revealTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
containerView.isHidden = false
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.12,
|
||||||
|
animations: { self.containerView.alpha = 1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAnimatingView() {
|
||||||
|
revealTask?.cancel()
|
||||||
|
revealTask = nil
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
containerView.alpha = 0
|
||||||
|
containerView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeStrategy(in imageView: KFCrossPlatformImageView) -> IndicatorSizeStrategy {
|
||||||
|
.size(CGSize(width: 44, height: 44))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||||
@@ -1054,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
imageView.contentMode = .scaleAspectFit
|
||||||
imageView.backgroundColor = .black
|
imageView.backgroundColor = .black
|
||||||
imageView.kf.indicatorType = .activity
|
imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator())
|
||||||
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
||||||
|
|
||||||
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
let source = TravelAlbumAIJobSourceMaterial(
|
let source = TravelAlbumAIJobSourceMaterial(
|
||||||
id: 691,
|
id: 691,
|
||||||
fileName: "IMG_8293.JPG",
|
fileName: "beauty_1757851944526_这个文件标题特别长用于验证布局不会挤压状态.jpg",
|
||||||
thumbnailURL: ""
|
thumbnailURL: ""
|
||||||
)
|
)
|
||||||
let failedTarget = TravelAlbumAIJobTarget(
|
let failedTarget = TravelAlbumAIJobTarget(
|
||||||
@@ -104,6 +104,42 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
|
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
|
||||||
XCTAssertFalse(labels.contains("去相册处理"))
|
XCTAssertFalse(labels.contains("去相册处理"))
|
||||||
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
|
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
|
||||||
|
let contentCard = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.detail.contentCard"
|
||||||
|
})
|
||||||
|
let refinedChip = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.refined"
|
||||||
|
})
|
||||||
|
let atmosphereChip = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.atmosphere"
|
||||||
|
})
|
||||||
|
let coverChip = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.cover"
|
||||||
|
})
|
||||||
|
XCTAssertEqual(refinedChip.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF)
|
||||||
|
XCTAssertEqual(atmosphereChip.backgroundColor?.travelAlbumTestHexRGB, 0xFFF2DC)
|
||||||
|
XCTAssertEqual(coverChip.backgroundColor?.travelAlbumTestHexRGB, 0xF2ECFF)
|
||||||
|
XCTAssertNil(refinedChip.findSubview { $0 is UIImageView })
|
||||||
|
XCTAssertNil(atmosphereChip.findSubview { $0 is UIImageView })
|
||||||
|
XCTAssertNil(coverChip.findSubview { $0 is UIImageView })
|
||||||
|
XCTAssertGreaterThan(atmosphereChip.bounds.width, refinedChip.bounds.width)
|
||||||
|
let coverFrame = coverChip.convert(coverChip.bounds, to: contentCard)
|
||||||
|
XCTAssertLessThan(coverFrame.maxX, contentCard.bounds.maxX - 16)
|
||||||
|
let titleLabel = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.target.3.title"
|
||||||
|
})
|
||||||
|
let templateLabel = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.target.3.template"
|
||||||
|
})
|
||||||
|
let statusView = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.target.3.status"
|
||||||
|
})
|
||||||
|
let titleFrame = titleLabel.convert(titleLabel.bounds, to: controller.view)
|
||||||
|
let templateFrame = templateLabel.convert(templateLabel.bounds, to: controller.view)
|
||||||
|
let statusFrame = statusView.convert(statusView.bounds, to: controller.view)
|
||||||
|
XCTAssertLessThanOrEqual(titleFrame.maxX, controller.view.bounds.maxX - 18)
|
||||||
|
XCTAssertGreaterThan(statusFrame.minY, templateFrame.maxY)
|
||||||
|
XCTAssertLessThanOrEqual(statusFrame.maxX, controller.view.bounds.maxX - 18)
|
||||||
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
||||||
window.layer.render(in: context.cgContext)
|
window.layer.render(in: context.cgContext)
|
||||||
}
|
}
|
||||||
@@ -315,6 +351,56 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5)
|
XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewAppliesRequestedInitialKindForCoverOnlyProject() throws {
|
||||||
|
let cover = TravelAlbumPreviewAsset(
|
||||||
|
id: "cover-897",
|
||||||
|
kind: .cover,
|
||||||
|
fileURL: "https://example.com/final.png",
|
||||||
|
coverURL: "https://example.com/final.png",
|
||||||
|
fileName: "AI封面",
|
||||||
|
fileSize: 0
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [TravelAlbumPreviewProject(originalMaterialId: 897, assets: [cover])],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
startKind: .cover,
|
||||||
|
allowsActions: false
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let highResolutionButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
let loadingIndicator = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewLoadingIndicator"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let activityIndicator = try XCTUnwrap(
|
||||||
|
loadingIndicator.findSubview { $0 is UIActivityIndicatorView } as? UIActivityIndicatorView
|
||||||
|
)
|
||||||
|
let fileSizeLabel = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewFileSizeLabel"
|
||||||
|
} as? UILabel
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "AI封面" })
|
||||||
|
XCTAssertTrue(highResolutionButton.isEnabled)
|
||||||
|
XCTAssertTrue(fileSizeLabel.isHidden)
|
||||||
|
XCTAssertNil(fileSizeLabel.text)
|
||||||
|
XCTAssertEqual(fileSizeLabel.bounds.height, 0, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(loadingIndicator.bounds.size, CGSize(width: 44, height: 44))
|
||||||
|
XCTAssertEqual(loadingIndicator.backgroundColor?.travelAlbumTestHexRGB, 0x27272A)
|
||||||
|
XCTAssertEqual(loadingIndicator.accessibilityLabel, "图片加载中")
|
||||||
|
XCTAssertEqual(activityIndicator.color.travelAlbumTestHexRGB, 0xFFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
api.deleteMaterialDelayNanoseconds = 1_000_000
|
api.deleteMaterialDelayNanoseconds = 1_000_000
|
||||||
|
|||||||
Reference in New Issue
Block a user