Basically what I am trying to do is cache the cell and have the video keep playing. When the user scroll back to the cell, the video should just show from where it was playing.
The problem is that the player gets removed and the cell ends up on a random cell instead of its designated area.
You will need to have two videos for this to work, I downloaded the videos off of here https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4 I just saved the same video twice under two different names.
How to replicate the problem: Tap the first cell, then scroll all the way down and then scroll back up, you will notice the video starts appearing all over the place. I just want the video to appear in its proper location and no where else.
Here is a link for the code: Code link
class ViewController: UIViewController {
private var collectionView: UICollectionView!
private var videosURLs: [String] = [
"ElephantsDream2", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream",
"ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream",
"ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream",
"ElephantsDream", "ElephantsDream", "ElephantsDream", "ElephantsDream"
]
var cacheItem = [String: (cell: CustomCell, player: AVPlayer)]()
override func viewDidLoad() {
super.viewDidLoad()
setupCollectionView()
}
private func setupCollectionView() {
collectionView = UICollectionView(frame: .zero, collectionViewLayout: ColumnFlowLayout())
view.addSubview(collectionView)
collectionView.register(CustomCell.self, forCellWithReuseIdentifier: "cell")
collectionView.dataSource = self
collectionView.delegate = self
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
}
extension ViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let cell = collectionView.cellForItem(at: indexPath) as? CustomCell else { return }
let item = videosURLs[indexPath.row]
let viewModel = PlayerViewModel(fileName: item)
cell.setupPlayerView(viewModel.player)
cacheItem[item] = (cell, viewModel.player)
}
}
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
videosURLs.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let item = videosURLs[indexPath.row]
if let cachedItem = cacheItem[item], indexPath.row == 0 {
print(indexPath)
print(item)
cachedItem.cell.setUpFromCache(cachedItem.player)
return cachedItem.cell
} else {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as? CustomCell else { return UICollectionViewCell() }
cell.contentView.backgroundColor = .orange
let url = Bundle.main.url(forResource: item, withExtension: "mp4")
cell.playerItem = AVPlayerItem(url: url!)
return cell
}
}
}
class CustomCell: UICollectionViewCell {
private var cancelBag: Set<AnyCancellable> = []
private(set) var playerView: PlayerView?
var playerItem: AVPlayerItem?
override var reuseIdentifier: String?{
"cell"
}
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
override func prepareForReuse() {
super.prepareForReuse()
playerView = nil
playerView?.removeFromSuperview()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupViews() {
layer.cornerRadius = 8
clipsToBounds = true
}
func setUpFromCache(_ player: AVPlayer) {
playerView?.player = player
}
func setupPlayerView(_ player: AVPlayer) {
if self.playerView == nil {
self.playerView = PlayerView(player: player, gravity: .aspectFill)
contentView.addSubview(playerView!)
playerView?.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playerView!.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
playerView!.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
playerView!.topAnchor.constraint(equalTo: contentView.topAnchor),
playerView!.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
playerView?.player?.play()
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime).sink { [weak self] notification in
if let p = notification.object as? AVPlayerItem, p == player.currentItem {
self?.playerView?.removeFromSuperview()
guard let self = self else { return }
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
}
}.store(in: &cancelBag)
} else {
playerView?.player?.pause()
playerView?.removeFromSuperview()
playerView = nil
}
}
}
Here are a few things I would think about / consider changing in trying to implement what you want and which might be the reason we see this strange cell behavior:
Use indexPath.item
rather indexPath.row
when dealing with collection views
In you prepareForReuse
, you actually discard the playerView
so when you try to restore the player again from cache playerView?.player = player
- the playerView
is most likely nil and needs to be reinitialized
You don't need to hang onto the the cell as a whole, this might work, however I think we should let collection view do its business of recycling the cell. Hang onto just the player instead
I have not done it, however, think about how handle removing notification observers when discarding the cells as you might be subscribing to a notification more than once and this could cause issues down the line
I also haven't done this, but remember to remove the video from your cache when you the video finishes since you don't want the cell to react anymore
Here are some small changes I made:
CustomCell
// I made this code into a function since I figured we might reuse it
private func configurePlayerView(_ player: AVPlayer) {
self.playerView = PlayerView(player: player, gravity: .aspectFill)
contentView.addSubview(playerView!)
playerView?.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playerView!.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
playerView!.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
playerView!.topAnchor.constraint(equalTo: contentView.topAnchor),
playerView!.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
playerView?.player?.play()
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime).sink { [weak self] notification in
if let p = notification.object as? AVPlayerItem, p == player.currentItem {
self?.playerView?.removeFromSuperview()
guard let self = self else { return }
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
}
}.store(in: &cancelBag)
}
func setUpFromCache(_ player: AVPlayer) {
// Check if the playerView needs to be set up
if playerView == nil {
configurePlayerView(player)
}
playerView?.player = player
}
func setupPlayerView(_ player: AVPlayer) {
// Replace the code from here and use the function
if self.playerView == nil {
configurePlayerView(player)
} else {
playerView?.player?.pause()
playerView?.removeFromSuperview()
playerView = nil
}
}
// No big change, this might have no impact, I changed the order
// of operations. You can use your previous code if it makes no difference
// but I added it for completeness
override func prepareForReuse() {
playerView?.removeFromSuperview()
playerView = nil
super.prepareForReuse()
}
ViewController
// I use the index path as a whole as the key
// I only store a reference to the player, not the cell
var cacheItem = [IndexPath: AVPlayer]()
// Some changes have to be made to made to support the new
// cache type
func collectionView(_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
guard let cell
= collectionView.cellForItem(at: indexPath) as? CustomCell else { return }
let item = videosURLs[indexPath.item]
let viewModel = PlayerViewModel(fileName: item)
cell.setupPlayerView(viewModel.player)
cacheItem[indexPath] = (viewModel.player)
}
// I set up a regular cell here
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell
= collectionView.dequeueReusableCell(withReuseIdentifier: "cell",
for: indexPath) as? CustomCell else { return UICollectionViewCell() }
let item = videosURLs[indexPath.row]
cell.contentView.backgroundColor = .orange
let url = Bundle.main.url(forResource: item, withExtension: "mp4")
cell.playerItem = AVPlayerItem(url: url!)
return cell
}
// I manage restoring an already playing cell here
func collectionView(_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath)
{
if let cachedItem = cacheItem[indexPath],
let cell = cell as? CustomCell
{
print("playing")
print(indexPath)
print(cachedItem)
cell.setUpFromCache(cachedItem)
}
}
After these changes, I think you will get what you want: