feat: 更新素材管理和举报风险地图
This commit is contained in:
574
suixinkan/UI/Common/MediaPreviewViewController.swift
Normal file
574
suixinkan/UI/Common/MediaPreviewViewController.swift
Normal file
@ -0,0 +1,574 @@
|
||||
//
|
||||
// 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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user