Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Invalidation Contexts for UICollectionViewLayout

So I have implemented working sticky headers in my UICollectionView in part by returning YES from shouldInvalidateLayoutForBoundsChange:. However, this impacts performance and I do not want to invalidate the entire layout, only my header section.

Now, according to the official documentation I can use UICollectionViewLayoutInvalidationContext to define a custom invalidation context for my layout, but the documentation is very lacking. It asks me to "define custom properties that represent the parts of your layout data that can be recomputed independently", but I don't understand what they mean by this.

Has anyone got any experience subclassing UICollectionViewLayoutInvalidationContext?

like image 339
mattsson Avatar asked Nov 20 '13 15:11

mattsson


3 Answers

This is for iOS8

I experimented a bit and I think I figured out the clean way to use the invalidation layout, at least until Apple expands on the documentation a bit.

The problem I was trying to solve was getting sticky headers in the collection view. I had working code for this using the subclass of FlowLayout and overriding layoutAttributesForElementsInRect: (you can find working examples on google). This required me to always return true from shouldInvalidateLayoutForBoundsChange: which is the supposed major performance kick in the nuts that Apple wants us to avoid with contextual invalidation.

The Clean Context Invalidation

You only need to subclass the UICollectionViewFlowLayout. I didn't need a subclass for UICollectionViewLayoutInvalidationContext, but then this might be a pretty straightforward use case.

As the collection view scrolls, the flow layout will start receiving shouldInvalidateLayoutForBoundsChange: calls. Since flow layout can already handle this, we'll return the superclass' answer at the end of the function. With simple scrolling this will be false, and will not re-layout the elements. But we need to re-layout the headers and have them stay at the top of the screen, so we'll tell the collection view to invalidate only the context that we'll provide:

override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
    invalidateLayoutWithContext(invalidationContextForBoundsChange(newBounds))
    return super.shouldInvalidateLayoutForBoundsChange(newBounds)
}

This means we need to override the invalidationContextForBoundsChange: function too. Since the internal workings of this function are unknown, we'll just ask the superclass for the invalidation context object, determine which collection view elements we want to invalidate, and add those elements to the invalidation context. I took some of the code out to focus on the essentials here:

override func invalidationContextForBoundsChange(newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext! {

    var context = super.invalidationContextForBoundsChange(newBounds)

    if /... we find a header in newBounds that needs to be invalidated .../ {

            context.invalidateSupplementaryElementsOfKind(UICollectionElementKindSectionHeader, atIndexPaths:[NSIndexPath(forItem: 0, inSection:headerIndexPath.section)] )
    }
    return context
}

That's it. The header and nothing but the header is invalidated. The flow layout will receive only one call to layoutAttributesForSupplementaryViewOfKind: with the indexPath in the invalidation context. If you needed to invalidate cells or decorators, there are other invalidate* functions on the UICollectionViewLayoutInvalidationContext.

The hardest part really is determining the indexPaths of the headers in the invalidationContextForBoundsChange: function. Both my headers and cells are dynamically sized and it took some acrobatics to get it to work from just looking at the bounds CGRect, since the most obviously helpful function, indexPathForItemAtPoint:, returns nothing if the point is on a header, footer, decorator or row spacing.

As for the performance, I didn't do a full measurement, but a quick glance at Time Profiler while scrolling shows that it's doing something right (the smaller spike on the right is while scrolling). UICollectionViewLayoutInvalidationContext performance comparison

like image 132
meelawsh Avatar answered Nov 19 '22 10:11

meelawsh


I was just asking the same question about this today and also got confused with the part: "define custom properties that represent the parts of your layout data that can be recomputed independently"

What I did was subclass UICollectionViewLayoutInvalidationContext (let's call it CustomInvalidationContext) and added my own property. For test purposes I wanted to find out where I could configure and retrieve these properties from the context, so I simply added an array as a property called "attributes".

Then in my subclassed UICollectionViewLayout I overwrite +invalidationContextClass to return an instance of CustomInvalidationContext which is returned in another method I overwrite which is: -invalidationContextForBoundsChange. In this method you need to call super which returns an instance of CustomInvalidationContext which you then configure the properties and return. I set the attributes array to have objects @["a","b","c"];

This is then later retrieved in yet another overwritten method -invalidateLayoutWithContext:. I was able to retrieve the attributes I set from the context passed in.

So what you can do is set properties that will later allow you to calculate what indexPaths to be supplied to -layoutAttributesForElementsInRect:.

Hope it helps.

like image 38
Peeks Avatar answered Nov 19 '22 10:11

Peeks


As of iOS 8

This answer was written before the iOS 8 seed. It mentions hoped for functionality that didn't quite exist in iOS 7 and offers a work around. This functionality does now exist and works. Another answer, currently further down the page, describes an approach for iOS 8.


Discussion

First up – with any kind of optimisation, the really important caution is to profile first and understand where exactly your performance bottleneck is.

I've looked at UICollectionViewLayoutInvalidationContext and agree it seems it might provide the features needed. In comments on the question I described my attempts to get this working. I now suspect that while it allows you to remove layout re-computations, it will not help you to avoid making layout changes to the content cells. In my case, layout computations are not especially expensive, but I do want to avoid the framework applying layout changes to the simple scrolling cells (of which I have quite a number), and only apply them to the "special" cells.

Implementation summary

In light of failing to do it as it seemed its intended by Apple, I have cheated. I use 2 UICollectionView instances. I have the normal scrolling content on a background view, and the headers on a second foreground view. The views' layouts specify that the background view doesn't invalidate on bounds change, and the foreground view does.

Implementation details

There are a number of non obvious things you need to get right to make this work, and I've also got a few tips for implementation that I found made my life easier. I'll go through this and provide snips of code taken from my application. I'm not going to provide a complete solution here, but I will give all the pieces that you need.

UICollectionView has a backgroundView property.

I create the background view in my UICollectionViewController's viewDidLoad method. By this point the view controller already has a UICollectionView instance in its collectionView property. This is going to be the foreground view and will be used for items with special scrolling behaviour such as pinning.

I create a second UICollectionView instance and set it as the backgroundView property of the foreground collection view. I set up the background to also use the UICollectionViewController subclass as it's datasource and delegate. I disable user interaction on the background view because it otherwise seems to get all events. You might require more subtle behaviour than this if you want selections, etc:

…
UICollectionView *const foregroundCollectionView = [self collectionView];
UICollectionView *const backgroundCollectionView = [[UICollectionView alloc] initWithFrame: [foregroundCollectionView frame] collectionViewLayout: [[STGridLayout alloc] init]];
[backgroundCollectionView setDataSource: self];
[backgroundCollectionView setDelegate: self];
[backgroundCollectionView setUserInteractionEnabled: NO];
[foregroundCollectionView setBackgroundView: backgroundCollectionView];
[(STGridLayout*)[backgroundCollectionView collectionViewLayout] setInvalidateLayoutForBoundsChange: NO];
[(STGridLayout*)[foregroundCollectionView collectionViewLayout] setInvalidateLayoutForBoundsChange: YES];
…

In summary – at this point we've got two collection views on top of each other. The back one will be used for static content. The front one will be for pinned content and such. They're both pointing to the same UICollectionViewController as their delegate and data source.

The invalidateLayoutForBoundsChange property on STGridLayout is something I've added to my custom layout. The layout simply returns it when -(BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds is called.

There's then more set up common to both views that in my case looks like this:

for(UICollectionView *collectionView in @[foregroundCollectionView, backgroundCollectionView])
{
  // Configure reusable views.
  [STCollectionViewStaticCVCell registerForReuseInView: collectionView];
  [STBlockRenderedCollectionViewCell registerForReuseInView: collectionView];
}

The registerForReuseInView: method is something added to UICollectionReusableView by a category, along with dequeueFromView:. The code for these is at the end of the answer.

The next piece to go in to viewDidLoad is the only major headache with this approach.

When you drag the foreground view you need the background view to scroll with it. I'll show the code for this in a moment: it simply mirrors the foreground view's contentOffset to the background view. However, you'll probably want the scrolling views to be able to "bounce" at the edges of the content. It seems that UICollectionView will clamp the contentOffset when it is programatically set, such that the content does not tear away from the UICollectionView's bounds. Without a remedy, only the foreground sticky elements will bounce, which looks horrible. However, adding the following to your viewDidLoad will fix this:

CGSize size = [foregroundCollectionView bounds].size;
[backgroundCollectionView setContentInset: UIEdgeInsetsMake(size.width, size.height, size.width, size.height)];

Unfortunately, this fix will mean that when you view appears on screen the content offset of the background won't match the foreground. To fix this you'll need to implement this:

-(void) viewDidAppear:(BOOL)animated
{
  [super viewDidAppear: animated];
  UICollectionView *const foregroundCollectionView = [self collectionView];
  UICollectionView *const backgroundCollectionView = (UICollectionView *)[foregroundCollectionView backgroundView];
  [backgroundCollectionView setContentOffset: [foregroundCollectionView contentOffset]];
}

I'm sure it would make more sense to do this in viewDidAppear:, but that didn't work for me.

The final important thing you need is to keep the background scrolling in synch with the foreground like this:

-(void) scrollViewDidScroll:(UIScrollView *const)scrollView
{
  UICollectionView *const collectionView = [self collectionView];
  if(scrollView == collectionView)
  {
    const CGPoint contentOffset = [collectionView contentOffset];
    UIScrollView *const backgroundView = (UIScrollView*)[collectionView backgroundView];
    [backgroundView setContentOffset: contentOffset];
  }
}

Implementation tips

These are some suggestions that have helped me implement UICollectionViewController's data source methods.

First up, I've used a section for each of the different kinds of view that are layered up. This worked well for me. I've not used UICollectionView's supplementary or decoration views. I give each section a name in a enum at the start of my view controller like this:

enum STSectionNumbers
{
  number_the_first_section_0_even_if_they_are_moved_during_editing = -1,

  // Section names. Order implies z with earlier sections drawn behind latter sections.
  STBackgroundCellsSection,
  STDataCellSection,
  STDayHeaderSection,
  STColumnHeaderSection,

  // The number of sections.
  STSectionCount,
};

In my UICollectionViewLayout subclass, when layout attributes are asked for, I set up the z property to meet the ordering like this:

-(UICollectionViewLayoutAttributes*) layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
  UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath: indexPath];
  const CGRect frame = …
  [attributes setFrame: frame];
  [attributes setZIndex: [indexPath section]];
  return attributes;
}

For me, the data source logic is much simpler if I give both of the UICollectionView instances all of the sections, but control which view really gets them by making the sections empty for the other.

Here's a handy method that I can use to check if a given UICollectionView really has a particular sections number:

-(BOOL) collectionView:(UICollectionView *const)collectionView hasSection:(const NSUInteger)section
{
  const BOOL isForegroundView = collectionView == [self collectionView];
  const BOOL isBackgroundView = !isForegroundView;

  switch (section)
  {
    case STBackgroundCellsSection:
    case STDataCellSection:
    {
      return isBackgroundView;
    }

    case STColumnHeaderSection:
    case STDayHeaderSection:
    {
      return isForegroundView;
    }

    default:
    {
      return NO;
    }
  }
}

With this in place, it's really easy to write the data source methods. Both views have the same section count, as I said, so:

-(NSInteger) numberOfSectionsInCollectionView:(UICollectionView *)collectionView
{
  return STSectionCount;
}

However, they have difference cell counts in the sections, but this is easy to accommodate

-(NSInteger) collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
  if(![self collectionView: collectionView hasSection: section])
  {
    return 0;
  }

  switch(section)
  {
    case STDataCellSection:
    {
      return … // (actual logic not shown)
    }

    case STBackgroundCellsSection:
    {
      return …
    }

    … // similarly for other sections.

    default:
    {
      return 0;
    }
  }
}

My UICollectionViewLayout subclass also has some view dependent methods it delegates to the UICollectionViewController subclass, but these are easily handled using the pattern above:

-(NSArray*) collectionViewRowRanges:(UICollectionView *)collectionView inSection:(NSInteger)section
{
  if(![self collectionView: collectionView hasSection: section])
  {
    return [NSArray array];
  }

  switch(section)
  {
    case STDataCellSection:
    {
      return … // (actual logic omitted)
      }
    }

    case STBackgroundCellsSection:
    {
      return …
    }

    … // etc for other sections

    default:
    {
      return [NSArray array];
    }
  }
}

As a sanity check, I ensure the collection views only ask for cells from the sections that they should be displaying:

-(UICollectionViewCell*) collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
  assert([self collectionView: collectionView hasSection: [indexPath section]] && "Check views are only asking for the sections they own.");

  switch([indexPath section])
  {
    case STBackgroundCellsSection:
    … // You get the idea.

Finally, it's worth noting that as shown in another SA answer the maths for sticky sections are simpler than I imagined they would be provided that you think about everything (including the device's screen) as being in the content space of the collection view.

Code for UICollectionReusableView Reuse category

@interface UICollectionReusableView (Reuse)
+(void) registerForReuseInView: (UICollectionView*) view;
+(id) dequeueFromView: (UICollectionView*) view withIndexPath: (NSIndexPath *) indexPath;
@end

It's implementation is:

@implementation UICollectionReusableView (Reuse)
+(void) registerForReuseInView: (UICollectionView*) view
{
  [view registerClass: self forCellWithReuseIdentifier: NSStringFromClass(self)];
}

+(instancetype) dequeueFromView: (UICollectionView*) view withIndexPath: (NSIndexPath *) indexPath
{
  return [view dequeueReusableCellWithReuseIdentifier:NSStringFromClass(self) forIndexPath: indexPath];
}
@end
like image 4
Benjohn Avatar answered Nov 19 '22 11:11

Benjohn