feat: 优化相册图片预览交互
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user