How to Cache Cells and Also Reuse Cells in a Collectionview That Has Avplayers Embedded in Each Cell

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

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

Proper way to deal with cell reuse with background threads?

Session 211 - Building Concurrent User Interfaces on iOS, from WWDC 2012, discusses the problem of the cell's image changing as the background tasks catch up (starting at 38m15s).

Here's how you solve that problem. After you've loaded the image on the background queue, use the index path to find the current cell, if any, that's displaying the item containing that image. If you get a cell, set the cell's image. If you get nil, there's no cell currently displaying that item so just discard the image.

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
MyCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
cell.imageView.image = nil;
dispatch_async(myQueue, ^{
[self bg_loadImageForRowAtIndexPath:indexPath];
});
return cell;
}

- (void)bg_loadImageForRowAtIndexPath:(NSIndexPath *)indexPath {
// I assume you have a method that returns the path of the image file. That
// method must be safe to run on the background queue.
NSString *path = [self bg_pathOfImageForItemAtIndexPath:indexPath];
UIImage *image = [UIImage imageWithContentsOfFile:path];
// Make sure the image actually loads its data now.
[image CGImage];

dispatch_async(dispatch_get_main_queue(), ^{
[self setImage:image forItemAtIndexPath:indexPath];
});
}

- (void)setImage:(UIImage *)image forItemAtIndexPath:(NSIndexPath *)indexPath {
MyCell *cell = (MyCell *)[collectionView cellForItemAtIndexPath:indexPath];
// If cell is nil, the following line has no effect.
cell.imageView.image = image;
}

Here's an easy way to reduce the "stacking up" of background tasks loading images that the view no longer needs. Give yourself an NSMutableSet instance variable:

@implementation MyViewController {
dispatch_queue_t myQueue;
NSMutableSet *indexPathsNeedingImages;
}

When you need to load an image, put the index path of the cell in the set and dispatch a task that takes one index path out of the set and loads its image:

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
MyCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
cell.imageView.image = nil;
[self addIndexPathToImageLoadingQueue:indexPath];
return cell;
}

- (void)addIndexPathToImageLoadingQueue:(NSIndexPath *)indexPath {
if (!indexPathsNeedingImages) {
indexPathsNeedingImages = [NSMutableSet set];
}
[indexPathsNeedingImages addObject:indexPath];
dispatch_async(myQueue, ^{ [self bg_loadOneImage]; });
}

If a cell stops being displayed, remove the cell's index path from the set:

- (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath {
[indexPathsNeedingImages removeObject:indexPath];
}

To load an image, get an index path from the set. We have to dispatch back to the main queue for this, to avoid a race condition on the set:

- (void)bg_loadOneImage {
__block NSIndexPath *indexPath;
dispatch_sync(dispatch_get_main_queue(), ^{
indexPath = [indexPathsNeedingImages anyObject];
if (indexPath) {
[indexPathsNeedingImages removeObject:indexPath];
}
}

if (indexPath) {
[self bg_loadImageForRowAtIndexPath:indexPath];
}
}

The bg_loadImageForRowAtIndexPath: method is the same as above. But when we actually set the image in the cell, we also want to remove the cell's index path from the set:

- (void)setImage:(UIImage *)image forItemAtIndexPath:(NSIndexPath *)indexPath {
MyCell *cell = (MyCell *)[collectionView cellForItemAtIndexPath:indexPath];
// If cell is nil, the following line has no effect.
cell.imageView.image = image;

[indexPathsNeedingImages removeObject:indexPath];
}

UICollectionView Self Sizing Cells with Auto Layout

This answer is outdated from iOS 14 with the addition of compositional layouts. Please consider updating the new API

Updated for Swift 5

preferredLayoutAttributesFittingAttributes renamed to preferredLayoutAttributesFitting and use auto sizing



Updated for Swift 4

systemLayoutSizeFittingSize renamed to systemLayoutSizeFitting



Updated for iOS 9

After seeing my GitHub solution break under iOS 9 I finally got the time to investigate the issue fully. I have now updated the repo to include several examples of different configurations for self sizing cells. My conclusion is that self sizing cells are great in theory but messy in practice. A word of caution when proceeding with self sizing cells.

TL;DR

Check out my GitHub project


Self sizing cells are only supported with flow layout so make sure thats what you are using.

There are two things you need to setup for self sizing cells to work.

#1. Set estimatedItemSize on UICollectionViewFlowLayout

Flow layout will become dynamic in nature once you set the estimatedItemSize property.

self.flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize

#2. Add support for sizing on your cell subclass

This comes in 2 flavours; Auto-Layout or custom override of preferredLayoutAttributesFittingAttributes.

Create and configure cells with Auto Layout

I won't go to in to detail about this as there's a brilliant SO post about configuring constraints for a cell. Just be wary that Xcode 6 broke a bunch of stuff with iOS 7 so, if you support iOS 7, you will need to do stuff like ensure the autoresizingMask is set on the cell's contentView and that the contentView's bounds is set as the cell's bounds when the cell is loaded (i.e. awakeFromNib).

Things you do need to be aware of is that your cell needs to be more seriously constrained than a Table View Cell. For instance, if you want your width to be dynamic then your cell needs a height constraint. Likewise, if you want the height to be dynamic then you will need a width constraint to your cell.

Implement preferredLayoutAttributesFittingAttributes in your custom cell

When this function is called your view has already been configured with content (i.e. cellForItem has been called). Assuming your constraints have been appropriately set you could have an implementation like this:

//forces the system to do one layout pass
var isHeightCalculated: Bool = false

override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
//Exhibit A - We need to cache our calculation to prevent a crash.
if !isHeightCalculated {
setNeedsLayout()
layoutIfNeeded()
let size = contentView.systemLayoutSizeFitting(layoutAttributes.size)
var newFrame = layoutAttributes.frame
newFrame.size.width = CGFloat(ceilf(Float(size.width)))
layoutAttributes.frame = newFrame
isHeightCalculated = true
}
return layoutAttributes
}

NOTE On iOS 9 the behaviour changed a bit that could cause crashes on your implementation if you are not careful (See more here). When you implement preferredLayoutAttributesFittingAttributes you need to ensure that you only change the frame of your layout attributes once. If you don't do this the layout will call your implementation indefinitely and eventually crash. One solution is to cache the calculated size in your cell and invalidate this anytime you reuse the cell or change its content as I have done with the isHeightCalculated property.

Experience your layout

At this point you should have 'functioning' dynamic cells in your collectionView. I haven't yet found the out-of-the box solution sufficient during my tests so feel free to comment if you have. It still feels like UITableView wins the battle for dynamic sizing IMHO.

##Caveats
Be very mindful that if you are using prototype cells to calculate the estimatedItemSize - this will break if your XIB uses size classes. The reason for this is that when you load your cell from a XIB its size class will be configured with Undefined. This will only be broken on iOS 8 and up since on iOS 7 the size class will be loaded based on the device (iPad = Regular-Any, iPhone = Compact-Any). You can either set the estimatedItemSize without loading the XIB, or you can load the cell from the XIB, add it to the collectionView (this will set the traitCollection), perform the layout, and then remove it from the superview. Alternatively you could also make your cell override the traitCollection getter and return the appropriate traits. It's up to you.





Related Topics



Leave a reply



Submit