重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
585 lines
20 KiB
Swift
585 lines
20 KiB
Swift
//
|
||
// MediaPreviewViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import AVFoundation
|
||
import Kingfisher
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 通用全屏媒体预览页,支持图片、视频混排横向预览。
|
||
class MediaPreviewViewController: UIViewController {
|
||
private enum Section {
|
||
case main
|
||
}
|
||
|
||
private let items: [MediaPreviewItem]
|
||
private let startIndex: Int
|
||
private var currentIndex: Int
|
||
private var dataSource: UICollectionViewDiffableDataSource<Section, MediaPreviewItem>!
|
||
|
||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionLayout())
|
||
private let closeButton = UIButton(type: .system)
|
||
private let counterLabel = UILabel()
|
||
|
||
/// 创建通用媒体预览页。
|
||
init(items: [MediaPreviewItem], startIndex: Int = 0) {
|
||
self.items = items
|
||
self.startIndex = MediaPreviewItem.clampedStartIndex(startIndex, itemCount: items.count)
|
||
self.currentIndex = self.startIndex
|
||
super.init(nibName: nil, bundle: nil)
|
||
modalPresentationStyle = .fullScreen
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 从指定控制器全屏弹出媒体预览页。
|
||
static func present(from viewController: UIViewController, items: [MediaPreviewItem], startIndex: Int = 0) {
|
||
guard !items.isEmpty else { return }
|
||
let preview = MediaPreviewViewController(items: items, startIndex: startIndex)
|
||
viewController.present(preview, animated: true)
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
setupUI()
|
||
setupDataSource()
|
||
applySnapshot()
|
||
registerNotifications()
|
||
}
|
||
|
||
override func viewDidLayoutSubviews() {
|
||
super.viewDidLayoutSubviews()
|
||
guard collectionView.bounds.width > 0 else { return }
|
||
collectionView.collectionViewLayout.invalidateLayout()
|
||
collectionView.setContentOffset(
|
||
CGPoint(x: CGFloat(currentIndex) * collectionView.bounds.width, y: 0),
|
||
animated: false
|
||
)
|
||
updateVisibleCellsPlayback()
|
||
}
|
||
|
||
override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
updateVisibleCellsPlayback()
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
pauseVisibleVideos()
|
||
}
|
||
|
||
deinit {
|
||
NotificationCenter.default.removeObserver(self)
|
||
}
|
||
|
||
private func setupUI() {
|
||
view.backgroundColor = .black
|
||
collectionView.backgroundColor = .black
|
||
collectionView.isPagingEnabled = true
|
||
collectionView.showsHorizontalScrollIndicator = false
|
||
collectionView.alwaysBounceHorizontal = items.count > 1
|
||
collectionView.delegate = self
|
||
collectionView.register(MediaPreviewImageCell.self, forCellWithReuseIdentifier: MediaPreviewImageCell.reuseIdentifier)
|
||
collectionView.register(MediaPreviewVideoCell.self, forCellWithReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier)
|
||
|
||
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||
closeButton.tintColor = .white
|
||
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||
closeButton.layer.cornerRadius = 18
|
||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||
|
||
counterLabel.textColor = .white
|
||
counterLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||
counterLabel.textAlignment = .center
|
||
counterLabel.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||
counterLabel.layer.cornerRadius = 14
|
||
counterLabel.clipsToBounds = true
|
||
updateCounter()
|
||
|
||
view.addSubview(collectionView)
|
||
view.addSubview(closeButton)
|
||
view.addSubview(counterLabel)
|
||
|
||
collectionView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
closeButton.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||
make.trailing.equalToSuperview().inset(16)
|
||
make.size.equalTo(36)
|
||
}
|
||
counterLabel.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
make.centerY.equalTo(closeButton)
|
||
make.height.equalTo(28)
|
||
make.width.greaterThanOrEqualTo(64)
|
||
}
|
||
}
|
||
|
||
private func setupDataSource() {
|
||
dataSource = UICollectionViewDiffableDataSource<Section, MediaPreviewItem>(
|
||
collectionView: collectionView
|
||
) { [weak self] collectionView, indexPath, item in
|
||
switch item.source {
|
||
case .remoteImage, .localImageFile, .image:
|
||
guard let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: MediaPreviewImageCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as? MediaPreviewImageCell else {
|
||
return UICollectionViewCell()
|
||
}
|
||
cell.configure(source: item.source)
|
||
return cell
|
||
case .remoteVideo, .localVideoFile:
|
||
guard let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as? MediaPreviewVideoCell else {
|
||
return UICollectionViewCell()
|
||
}
|
||
cell.configure(source: item.source)
|
||
cell.setActive(indexPath.item == self?.currentIndex)
|
||
return cell
|
||
}
|
||
}
|
||
}
|
||
|
||
private func applySnapshot() {
|
||
var snapshot = NSDiffableDataSourceSnapshot<Section, MediaPreviewItem>()
|
||
snapshot.appendSections([.main])
|
||
snapshot.appendItems(items)
|
||
dataSource.apply(snapshot, animatingDifferences: false)
|
||
}
|
||
|
||
private func makeCollectionLayout() -> UICollectionViewLayout {
|
||
let layout = UICollectionViewFlowLayout()
|
||
layout.scrollDirection = .horizontal
|
||
layout.minimumLineSpacing = 0
|
||
layout.minimumInteritemSpacing = 0
|
||
return layout
|
||
}
|
||
|
||
private func registerNotifications() {
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidEnterBackground),
|
||
name: UIApplication.didEnterBackgroundNotification,
|
||
object: nil
|
||
)
|
||
}
|
||
|
||
private func updateCurrentIndex() {
|
||
guard collectionView.bounds.width > 0 else { return }
|
||
let page = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
|
||
currentIndex = min(max(0, page), max(0, items.count - 1))
|
||
updateCounter()
|
||
updateVisibleCellsPlayback()
|
||
}
|
||
|
||
private func updateCounter() {
|
||
counterLabel.text = items.isEmpty ? "0/0" : "\(currentIndex + 1)/\(items.count)"
|
||
}
|
||
|
||
private func updateVisibleCellsPlayback() {
|
||
for cell in collectionView.visibleCells {
|
||
guard let indexPath = collectionView.indexPath(for: cell),
|
||
let videoCell = cell as? MediaPreviewVideoCell
|
||
else { continue }
|
||
videoCell.setActive(indexPath.item == currentIndex && view.window != nil)
|
||
}
|
||
}
|
||
|
||
private func pauseVisibleVideos() {
|
||
collectionView.visibleCells
|
||
.compactMap { $0 as? MediaPreviewVideoCell }
|
||
.forEach { $0.setActive(false) }
|
||
}
|
||
|
||
@objc private func closeTapped() {
|
||
pauseVisibleVideos()
|
||
dismiss(animated: true)
|
||
}
|
||
|
||
@objc private func appDidEnterBackground() {
|
||
pauseVisibleVideos()
|
||
}
|
||
}
|
||
|
||
extension MediaPreviewViewController: UICollectionViewDelegateFlowLayout {
|
||
func collectionView(
|
||
_ collectionView: UICollectionView,
|
||
layout collectionViewLayout: UICollectionViewLayout,
|
||
sizeForItemAt indexPath: IndexPath
|
||
) -> CGSize {
|
||
collectionView.bounds.size
|
||
}
|
||
|
||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||
updateCurrentIndex()
|
||
}
|
||
|
||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||
updateCurrentIndex()
|
||
}
|
||
|
||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||
guard scrollView.isDragging || scrollView.isDecelerating else { return }
|
||
pauseVisibleVideos()
|
||
}
|
||
}
|
||
|
||
/// 图片预览 Cell,支持远程、本地与内存图片缩放。
|
||
private final class MediaPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||
static let reuseIdentifier = "MediaPreviewImageCell"
|
||
|
||
private let scrollView = UIScrollView()
|
||
private let imageView = UIImageView()
|
||
private let messageLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func prepareForReuse() {
|
||
super.prepareForReuse()
|
||
imageView.kf.cancelDownloadTask()
|
||
imageView.image = nil
|
||
messageLabel.isHidden = true
|
||
scrollView.zoomScale = 1
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
imageView.frame = scrollView.bounds
|
||
}
|
||
|
||
/// 配置图片资源来源。
|
||
func configure(source: MediaPreviewItem.Source) {
|
||
messageLabel.isHidden = true
|
||
scrollView.zoomScale = 1
|
||
switch source {
|
||
case .remoteImage(let url):
|
||
imageView.kf.setImage(with: url) { [weak self] result in
|
||
if case .failure = result {
|
||
self?.showMessage("图片加载失败")
|
||
}
|
||
}
|
||
case .localImageFile(let url):
|
||
if let image = UIImage(contentsOfFile: url.path) {
|
||
imageView.image = image
|
||
} else {
|
||
showMessage("图片加载失败")
|
||
}
|
||
case .image(let image):
|
||
imageView.image = image
|
||
case .remoteVideo, .localVideoFile:
|
||
showMessage("不支持的图片资源")
|
||
}
|
||
}
|
||
|
||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||
imageView
|
||
}
|
||
|
||
private func setupUI() {
|
||
backgroundColor = .black
|
||
scrollView.delegate = self
|
||
scrollView.minimumZoomScale = 1
|
||
scrollView.maximumZoomScale = 3
|
||
scrollView.showsHorizontalScrollIndicator = false
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
scrollView.contentInsetAdjustmentBehavior = .never
|
||
|
||
imageView.contentMode = .scaleAspectFit
|
||
imageView.backgroundColor = .black
|
||
|
||
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
||
messageLabel.font = .systemFont(ofSize: 15)
|
||
messageLabel.textAlignment = .center
|
||
messageLabel.isHidden = true
|
||
|
||
contentView.addSubview(scrollView)
|
||
scrollView.addSubview(imageView)
|
||
contentView.addSubview(messageLabel)
|
||
|
||
scrollView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
messageLabel.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.leading.trailing.equalToSuperview().inset(32)
|
||
}
|
||
|
||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
|
||
doubleTap.numberOfTapsRequired = 2
|
||
scrollView.addGestureRecognizer(doubleTap)
|
||
}
|
||
|
||
private func showMessage(_ text: String) {
|
||
imageView.image = nil
|
||
messageLabel.text = text
|
||
messageLabel.isHidden = false
|
||
}
|
||
|
||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||
if scrollView.zoomScale > 1 {
|
||
scrollView.setZoomScale(1, animated: true)
|
||
return
|
||
}
|
||
let point = gesture.location(in: imageView)
|
||
let targetScale = scrollView.maximumZoomScale
|
||
let width = scrollView.bounds.width / targetScale
|
||
let height = scrollView.bounds.height / targetScale
|
||
let rect = CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height)
|
||
scrollView.zoom(to: rect, animated: true)
|
||
}
|
||
}
|
||
|
||
/// 视频预览 Cell,负责单个 AVPlayer 的播放与暂停。
|
||
private final class MediaPreviewVideoCell: UICollectionViewCell, UIGestureRecognizerDelegate {
|
||
static let reuseIdentifier = "MediaPreviewVideoCell"
|
||
|
||
private let playerView = MediaPreviewPlayerView()
|
||
private let playButton = MediaPreviewPlaybackButton()
|
||
private let messageLabel = UILabel()
|
||
private var player: AVPlayer?
|
||
private var didReachEnd = false
|
||
private var isActive = false
|
||
private var isUserPaused = false
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
deinit {
|
||
NotificationCenter.default.removeObserver(self)
|
||
}
|
||
|
||
override func prepareForReuse() {
|
||
super.prepareForReuse()
|
||
NotificationCenter.default.removeObserver(self)
|
||
player?.pause()
|
||
player = nil
|
||
playerView.playerLayer.player = nil
|
||
didReachEnd = false
|
||
isActive = false
|
||
isUserPaused = false
|
||
messageLabel.isHidden = true
|
||
playButton.isHidden = false
|
||
}
|
||
|
||
/// 配置视频资源来源。
|
||
func configure(source: MediaPreviewItem.Source) {
|
||
messageLabel.isHidden = true
|
||
didReachEnd = false
|
||
isUserPaused = false
|
||
let url: URL?
|
||
switch source {
|
||
case .remoteVideo(let remoteURL), .localVideoFile(let remoteURL):
|
||
url = remoteURL
|
||
case .remoteImage, .localImageFile, .image:
|
||
url = nil
|
||
}
|
||
guard let url else {
|
||
showMessage("视频加载失败")
|
||
return
|
||
}
|
||
|
||
let player = AVPlayer(url: url)
|
||
player.actionAtItemEnd = .pause
|
||
self.player = player
|
||
playerView.playerLayer.player = player
|
||
if let item = player.currentItem {
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(videoDidEnd),
|
||
name: .AVPlayerItemDidPlayToEndTime,
|
||
object: item
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 根据是否为当前页切换视频播放状态。
|
||
func setActive(_ active: Bool) {
|
||
isActive = active
|
||
guard let player else { return }
|
||
guard active else {
|
||
player.pause()
|
||
playButton.isHidden = !didReachEnd
|
||
return
|
||
}
|
||
if didReachEnd {
|
||
showPlaybackButton(systemName: "pause.fill")
|
||
} else if isUserPaused {
|
||
showPlaybackButton(systemName: "play.fill")
|
||
player.pause()
|
||
} else {
|
||
playButton.isHidden = true
|
||
player.play()
|
||
}
|
||
}
|
||
|
||
private func setupUI() {
|
||
backgroundColor = .black
|
||
playerView.playerLayer.videoGravity = .resizeAspect
|
||
|
||
playButton.setSymbol(named: "pause.fill")
|
||
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||
|
||
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
||
messageLabel.font = .systemFont(ofSize: 15)
|
||
messageLabel.textAlignment = .center
|
||
messageLabel.isHidden = true
|
||
|
||
contentView.addSubview(playerView)
|
||
contentView.addSubview(playButton)
|
||
contentView.addSubview(messageLabel)
|
||
|
||
playerView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
playButton.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(96)
|
||
}
|
||
messageLabel.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.leading.trailing.equalToSuperview().inset(32)
|
||
}
|
||
|
||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(videoTapped))
|
||
tapGesture.delegate = self
|
||
contentView.addGestureRecognizer(tapGesture)
|
||
}
|
||
|
||
private func showMessage(_ text: String) {
|
||
messageLabel.text = text
|
||
messageLabel.isHidden = false
|
||
playButton.isHidden = true
|
||
}
|
||
|
||
private func showPlaybackButton(systemName: String) {
|
||
playButton.setSymbol(named: systemName)
|
||
playButton.isHidden = false
|
||
}
|
||
|
||
private func startPlaybackFromCurrentPosition() {
|
||
guard let player else { return }
|
||
if didReachEnd {
|
||
player.seek(to: .zero)
|
||
didReachEnd = false
|
||
}
|
||
isUserPaused = false
|
||
playButton.isHidden = true
|
||
player.play()
|
||
}
|
||
|
||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||
guard touch.view?.isDescendant(of: playButton) != true else { return false }
|
||
return true
|
||
}
|
||
|
||
@objc private func playTapped() {
|
||
startPlaybackFromCurrentPosition()
|
||
}
|
||
|
||
@objc private func videoTapped() {
|
||
guard isActive, let player else { return }
|
||
if didReachEnd || isUserPaused {
|
||
startPlaybackFromCurrentPosition()
|
||
return
|
||
}
|
||
isUserPaused = true
|
||
player.pause()
|
||
showPlaybackButton(systemName: "play.fill")
|
||
}
|
||
|
||
@objc private func videoDidEnd() {
|
||
didReachEnd = true
|
||
isUserPaused = false
|
||
showPlaybackButton(systemName: "pause.fill")
|
||
}
|
||
}
|
||
|
||
/// 承载 AVPlayerLayer 的视频预览视图。
|
||
private final class MediaPreviewPlayerView: UIView {
|
||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||
}
|
||
|
||
/// 视频预览播放状态按钮,提供大号图标与柔和渐变背景。
|
||
private final class MediaPreviewPlaybackButton: UIButton {
|
||
private let gradientLayer = CAGradientLayer()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
gradientLayer.frame = bounds
|
||
gradientLayer.cornerRadius = bounds.width / 2
|
||
layer.cornerRadius = bounds.width / 2
|
||
}
|
||
|
||
/// 设置按钮当前展示的 SF Symbol。
|
||
func setSymbol(named systemName: String) {
|
||
let configuration = UIImage.SymbolConfiguration(pointSize: 42, weight: .semibold)
|
||
setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
|
||
}
|
||
|
||
private func setupUI() {
|
||
tintColor = .white
|
||
backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||
var buttonConfiguration = UIButton.Configuration.plain()
|
||
buttonConfiguration.baseForegroundColor = .white
|
||
buttonConfiguration.contentInsets = .zero
|
||
configuration = buttonConfiguration
|
||
configurationUpdateHandler = { button in
|
||
var updatedConfiguration = button.configuration
|
||
updatedConfiguration?.baseForegroundColor = .white
|
||
button.configuration = updatedConfiguration
|
||
button.alpha = 1
|
||
button.imageView?.alpha = 1
|
||
}
|
||
layer.masksToBounds = false
|
||
layer.shadowColor = UIColor.black.cgColor
|
||
layer.shadowOpacity = 0.28
|
||
layer.shadowRadius = 18
|
||
layer.shadowOffset = CGSize(width: 0, height: 10)
|
||
|
||
gradientLayer.colors = [
|
||
UIColor.white.withAlphaComponent(0.32).cgColor,
|
||
UIColor.black.withAlphaComponent(0.38).cgColor
|
||
]
|
||
gradientLayer.startPoint = CGPoint(x: 0.18, y: 0.12)
|
||
gradientLayer.endPoint = CGPoint(x: 0.88, y: 0.92)
|
||
layer.insertSublayer(gradientLayer, at: 0)
|
||
|
||
setSymbol(named: "pause.fill")
|
||
}
|
||
}
|