feat: 优化相册图片预览交互

This commit is contained in:
2026-08-12 10:58:53 +08:00
parent 010add828b
commit 05cfdc3662
2 changed files with 356 additions and 81 deletions
@@ -44,11 +44,16 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let counterLabel = UILabel()
private let bottomChrome = UIView()
private let divider = UIView()
private let tabStack = UIStackView()
private let variantControls = UIView()
private let variantSegmentedControl = UISegmentedControl()
private let highResolutionButton = UIButton(type: .system)
private let actionStack = UIStackView()
private let deleteButton = UIButton(type: .system)
private let refreshButton = UIButton(type: .system)
private var tabHeightConstraint: Constraint?
private var variantControlsHeightConstraint: Constraint?
private var didConfigureVariantControls = false
private var showsVariantControls = false
private var variantControlsAnimationGeneration = 0
/// 创建全屏预览页。
init(
@@ -84,7 +89,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
override var prefersStatusBarHidden: Bool { true }
override var prefersStatusBarHidden: Bool { false }
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] }
override func viewDidLoad() {
@@ -114,6 +120,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
collectionView.isPagingEnabled = true
collectionView.alwaysBounceHorizontal = nodes.count > 1
collectionView.showsHorizontalScrollIndicator = false
collectionView.accessibilityIdentifier = "travelAlbum.previewCollectionView"
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(
@@ -121,34 +128,57 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier
)
topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.72)
bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.78)
divider.backgroundColor = UIColor.white.withAlphaComponent(0.2)
topChrome.backgroundColor = .black
topChrome.accessibilityIdentifier = "travelAlbum.previewTopChrome"
bottomChrome.backgroundColor = .black
bottomChrome.accessibilityIdentifier = "travelAlbum.previewBottomChrome"
divider.backgroundColor = UIColor.white.withAlphaComponent(0.12)
closeButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)), for: .normal)
closeButton.tintColor = .white
var backConfiguration = UIButton.Configuration.plain()
backConfiguration.image = UIImage(systemName: "chevron.backward")
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
pointSize: 21,
weight: .semibold
)
backConfiguration.baseForegroundColor = .white
backConfiguration.contentInsets = .zero
closeButton.configuration = backConfiguration
closeButton.accessibilityLabel = "关闭图片预览"
closeButton.accessibilityIdentifier = "travelAlbum.previewBackButton"
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
titleLabel.textColor = .white
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.65)
sizeLabel.font = .systemFont(ofSize: 11, weight: .regular)
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
sizeLabel.textAlignment = .center
counterLabel.textColor = .white
counterLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
counterLabel.textAlignment = .right
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
counterLabel.textAlignment = .center
counterLabel.backgroundColor = UIColor(white: 0.11, alpha: 1)
counterLabel.layer.cornerRadius = 17
counterLabel.clipsToBounds = true
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
tabStack.alignment = .fill
variantSegmentedControl.selectedSegmentTintColor = UIColor(white: 0.22, alpha: 1)
variantSegmentedControl.backgroundColor = UIColor(white: 0.065, alpha: 1)
variantSegmentedControl.setTitleTextAttributes(
[.foregroundColor: UIColor.white.withAlphaComponent(0.62), .font: UIFont.systemFont(ofSize: 15)],
for: .normal
)
variantSegmentedControl.setTitleTextAttributes(
[.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 15, weight: .semibold)],
for: .selected
)
variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl"
variantSegmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
actionStack.axis = .horizontal
actionStack.distribution = .fillEqually
actionStack.alignment = .fill
configureHighResolutionButton()
configureActions()
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
@@ -163,23 +193,25 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
topChrome.addSubview(counterLabel)
view.addSubview(bottomChrome)
bottomChrome.addSubview(divider)
bottomChrome.addSubview(tabStack)
bottomChrome.addSubview(variantControls)
variantControls.addSubview(variantSegmentedControl)
variantControls.addSubview(highResolutionButton)
bottomChrome.addSubview(actionStack)
collectionView.snp.makeConstraints { $0.edges.equalToSuperview() }
topChrome.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(64)
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(72)
}
closeButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.bottom.equalToSuperview().offset(-10)
make.leading.equalToSuperview().offset(10)
make.bottom.equalToSuperview().offset(-14)
make.size.equalTo(44)
}
counterLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(closeButton)
make.width.greaterThanOrEqualTo(52)
make.width.greaterThanOrEqualTo(48)
make.height.equalTo(34)
}
titleStack.snp.makeConstraints { make in
make.centerY.equalTo(closeButton)
@@ -192,40 +224,72 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
bottomChrome.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(topChrome.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(-126.5)
}
divider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
tabStack.snp.makeConstraints { make in
variantControls.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom)
make.leading.trailing.equalToSuperview().inset(12)
tabHeightConstraint = make.height.equalTo(0).constraint
make.leading.trailing.equalToSuperview()
variantControlsHeightConstraint = make.height.equalTo(56).constraint
}
variantSegmentedControl.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(20)
make.centerY.equalToSuperview()
make.height.equalTo(40)
make.trailing.equalTo(highResolutionButton.snp.leading).offset(-12)
}
highResolutionButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(18)
make.centerY.equalToSuperview()
make.width.equalTo(116)
make.height.equalTo(40)
}
actionStack.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.top.equalTo(variantControls.snp.bottom)
make.leading.trailing.equalToSuperview().inset(8)
make.height.equalTo(70)
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
private func configureHighResolutionButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "查看高清图"
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = UIColor(white: 0.18, alpha: 1)
configuration.background.cornerRadius = 20
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 15, weight: .medium)
return attributes
}
highResolutionButton.configuration = configuration
highResolutionButton.accessibilityLabel = "查看高清图"
highResolutionButton.accessibilityIdentifier = "travelAlbum.previewHighResolutionButton"
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
}
private func configureActions() {
let aiButton = makeActionButton(title: "AI修图", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA))
let aiButton = makeActionButton(title: "AI修图", systemName: "tag", color: .white)
let deleteConfiguration = makeActionButton(
title: "删除",
systemName: "trash",
color: UIColor(hex: 0xF87171)
color: .white
).configuration
deleteButton.configuration = deleteConfiguration
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
deleteButton.accessibilityLabel = "删除"
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
refreshButton.configuration = makeActionButton(
title: "刷新",
systemName: "arrow.clockwise",
systemName: "arrow.counterclockwise.circle",
color: .white
).configuration
refreshButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
refreshButton.accessibilityLabel = "刷新"
refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton"
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
@@ -239,11 +303,19 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
configuration.title = title
configuration.image = UIImage(systemName: systemName)
configuration.imagePlacement = .top
configuration.imagePadding = 5
configuration.imagePadding = 8
configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
pointSize: 25,
weight: .regular
)
configuration.baseForegroundColor = color
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)
configuration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 6, bottom: 8, trailing: 6)
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 14, weight: .regular)
return attributes
}
let button = UIButton(configuration: configuration)
button.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
button.accessibilityLabel = title
return button
}
@@ -278,45 +350,100 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
highResolutionButton.isEnabled = asset != nil
highResolutionButton.alpha = asset == nil ? 0.45 : 1
rebuildTabs()
loadMoreIfNeeded(projectIndex: node.projectIndex)
}
private func rebuildTabs() {
tabStack.arrangedSubviews.forEach {
tabStack.removeArrangedSubview($0)
$0.removeFromSuperview()
while variantSegmentedControl.numberOfSegments > 0 {
variantSegmentedControl.removeSegment(at: 0, animated: false)
}
guard let project = currentProject, project.hasVariants else {
tabStack.isHidden = true
tabHeightConstraint?.update(offset: 0)
setVariantControlsVisible(false)
return
}
tabStack.isHidden = false
tabHeightConstraint?.update(offset: 44)
let activeKind = currentAsset?.kind ?? .original
for asset in project.orderedAssets {
let button = UIButton(type: .system)
button.tag = asset.kind.rawValue
button.setTitle(asset.kind.title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 13, weight: asset.kind == activeKind ? .semibold : .medium)
button.setTitleColor(asset.kind == activeKind ? .white : UIColor.white.withAlphaComponent(0.55), for: .normal)
button.backgroundColor = asset.kind == activeKind ? UIColor.white.withAlphaComponent(0.12) : .clear
button.layer.cornerRadius = 8
button.accessibilityLabel = asset.kind.title
button.accessibilityTraits = asset.kind == activeKind ? [.button, .selected] : .button
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
tabStack.addArrangedSubview(button)
}
view.layoutIfNeeded()
if !chromeVisible {
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
for (index, asset) in project.orderedAssets.enumerated() {
variantSegmentedControl.insertSegment(withTitle: asset.kind.title, at: index, animated: false)
}
variantSegmentedControl.selectedSegmentIndex = project.orderedAssets.firstIndex {
$0.kind == activeKind
} ?? 0
setVariantControlsVisible(true)
}
@objc private func tabTapped(_ sender: UIButton) {
guard let kind = TravelAlbumPreviewAssetKind(rawValue: sender.tag),
let node = currentNode,
private func setVariantControlsVisible(_ visible: Bool) {
let visibilityChanged = didConfigureVariantControls && showsVariantControls != visible
didConfigureVariantControls = true
showsVariantControls = visible
variantControlsAnimationGeneration += 1
let animationGeneration = variantControlsAnimationGeneration
let shouldAnimate = visibilityChanged
&& chromeVisible
&& view.window != nil
&& UIView.areAnimationsEnabled
&& !UIAccessibility.isReduceMotionEnabled
view.layoutIfNeeded()
variantControls.isHidden = false
variantSegmentedControl.isHidden = false
highResolutionButton.isHidden = false
variantControlsHeightConstraint?.update(offset: visible ? 56 : 0)
guard shouldAnimate else {
variantControls.alpha = 1
variantControls.transform = .identity
view.layoutIfNeeded()
variantControls.isHidden = !visible
variantSegmentedControl.isHidden = !visible
highResolutionButton.isHidden = !visible
updateHiddenBottomChromePositionIfNeeded()
return
}
if visible {
variantControls.alpha = 0
variantControls.transform = CGAffineTransform(translationX: 0, y: 8)
}
UIView.animate(
withDuration: 0.24,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction],
animations: {
self.variantControls.alpha = visible ? 1 : 0
self.variantControls.transform = visible
? .identity
: CGAffineTransform(translationX: 0, y: 8)
self.view.layoutIfNeeded()
},
completion: { [weak self] finished in
guard let self,
finished,
self.variantControlsAnimationGeneration == animationGeneration
else { return }
self.variantControls.isHidden = !visible
self.variantSegmentedControl.isHidden = !visible
self.highResolutionButton.isHidden = !visible
self.variantControls.alpha = 1
self.variantControls.transform = .identity
self.updateHiddenBottomChromePositionIfNeeded()
}
)
}
private func updateHiddenBottomChromePositionIfNeeded() {
guard !chromeVisible else { return }
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
}
@objc private func segmentChanged(_ sender: UISegmentedControl) {
guard let project = currentProject,
project.orderedAssets.indices.contains(sender.selectedSegmentIndex)
else { return }
let kind = project.orderedAssets[sender.selectedSegmentIndex].kind
guard let node = currentNode,
currentProject?.asset(for: kind) != nil
else { return }
switch configuration.swipeMode {
@@ -454,6 +581,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
dismiss(animated: true)
}
@objc private func highResolutionTapped() {
guard chromeVisible else { return }
toggleChrome()
}
@objc private func aiTapped() {
guard presentedViewController == nil else { return }
guard let project = currentProject else { return }
@@ -661,7 +793,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil
var configuration = refreshButton.configuration
configuration?.showsActivityIndicator = isRefreshingProject
configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise")
configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.counterclockwise.circle")
configuration?.title = isRefreshingProject ? "刷新中" : "刷新"
refreshButton.configuration = configuration
}