Search code examples
swiftxcodecollectionviewavkit

How do I cache cells and also reuse cells in a collectionview that has avplayers embedded in each cell?


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
        }
    }
}

Solution

  • 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:

    1. Use indexPath.item rather indexPath.row when dealing with collection views

    2. 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

    3. 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

    4. 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

    5. 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:

    AVPlayer resume restore in UICollectionView UIScrollView iOS Swift