// // 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! 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( 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() 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) adjustsImageWhenHighlighted = false 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") } }