重构排队设置与变更日志交互,补充 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")
|
|
}
|
|
}
|