Files
suixinkan_uikit/suixinkan/UI/Common/MediaPreviewViewController.swift
汉秋 5eef31b8da 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:51:23 +08:00

585 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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")
}
}