33

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?

Benjohn
  • 12,147
  • 8
  • 58
  • 110
mattsson
  • 1,311
  • 1
  • 14
  • 30
  • I'll be looking at this over the next few days. I'll add an answer if I get something. Agree docs are a bit light! What I can't find is: you need to override `UICollectionViewLayout`'s `invalidationContextForBoundsChange:` to provide an instance of your custom `UICollectionViewLayoutInvalidationContext` that says "only invalidate the headers / footers". You also need to override `invalidateLayoutWithContext:` so that it only invalidates the headers and footers if that's what context says. **But** I'm not sure how to actually mark the specific `UICollectionViewCell`s as invalid. – Benjohn Jan 09 '14 at 16:02
  • 2
    Right! `UICollectionViewCell` is a `UICollectionReusableView` subclass. This means it implements `applyLayoutAttributes:` – exactly what's needed. The documentation of `UICollectionView` mentions that `performBatchUpdates:completion:` can be used to "…change the layout parameters associated with one or more cells…". I suspect that your `UICollectionViewLayout` should override `invalidateLayoutWithContext:`, detect the case of a bounds change (see previous comment) and in this case, update just the visible header and footer cells. Or I could be completely wrong :-) – Benjohn Jan 09 '14 at 17:11
  • I overrode `applyLayoutAttributes:` of my `UICollectionViewCell` subclass to call `[super …]` and log the call. It's definitely getting used during updates, so it seems reasonably likely it can be called directly within `performBatchUpdates:completion:`. Definitely worth a go anyway. – Benjohn Jan 09 '14 at 17:37
  • Thanks for comments. Did you reach a point where you're using this? – mattsson Jan 24 '14 at 08:30
  • I've not yet! I got sidetracked :-) Still planning to head down this route though. Another work around I considered (that might have dire performance implications) is to have two `UICollectionsView`s with the content cells' collection view being the `backgroundView` behind the floating header cells' collection view. The background view's layout doesn't invalidate on bounds change. The foreground view's does. – Benjohn Jan 25 '14 at 09:16
  • @mattson I've been trying this today and had very little luck. Using `performBatchUpdates:completion:` was a red herring, I think it's just for animating changes. Simply calling `applyLayoutAttributes:` on appropriate cells with updated floating positions in `invalidateLayoutWithContext:` doesn't seem to do anything: they don't move. – Benjohn Jan 28 '14 at 14:00
  • @mattson I'm going to give up on this. If you are desperate, perhaps contact Apple tech support and use a support ticket (you get free ones with your subscription). If you want a work around, use **2** `UICollectionView` instances. Place static cells in one and moving cells in another. Use the `backgroundView` property of `UICollectionView` to place the static cell's view behind the dynamic one. You'll need to set the `contentOffset` on the back view to track the front one. In my case, I think it's going to be sufficiently performant with a simple layout invalidation on bounds change. – Benjohn Jan 28 '14 at 14:04
  • @mattson A closer reading of `applyLayoutAttributes:` documentation makes me think this isn't the call to use. Apple says that the collection view applies the attributes automatically. It seems like there is a "missing" piece of API to update just some of a `UICollectionView`'s cells. I also wonder if overriding `invalidateLayoutWithContext:` is only provided as a way to efficiently prepare the layout, not to optimise element positioning? – Benjohn Jan 28 '14 at 14:14
  • As of iOS 8, it is now possible to selectively invalidate some elements or sections in a layout. I got this working today transitioned from the approach that I describe below. I'll probably write this up and add it as a new answer at some point as there are a few wrinkles I didn't expect. – Benjohn Oct 15 '14 at 19:42
  • Sounds great, would love to see the write-up. – mattsson Oct 16 '14 at 07:11
  • 2
    Hi @mattsson – would you be happy to transfer the "correct answer" away from me and give it to [meelawsh](http://stackoverflow.com/a/24227056/2547229) instead? They give a better answer for iOS 8 onwards, which is likely to be more useful to people coming here. Thanks! – Benjohn Feb 17 '16 at 09:36
  • 2
    Holly cow! I lost 15 rep on that. :-) I love being punished for doing the right thing. – Benjohn Feb 19 '16 at 10:17
  • Haha, there should probably be a reward for asking to have your answer moved. :) – mattsson Feb 19 '16 at 10:45

5 Answers5

34

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

kender
  • 79,300
  • 24
  • 99
  • 144
meelawsh
  • 597
  • 5
  • 8
  • Works like a charm on iOS 8. On iOS 7, I had to switch to @Fabien Warniez's solution. – Dennis Oct 02 '14 at 14:15
  • 1
    Hey, would you mind sharing some additional details about this solution for sticky headers? ie: Are you using FlowLayout? if so, how are you getting the supplementary indexes to invalidate? Are you using `UICollectionElementKindSectionHeader` as kind for supplementary views? Thank you in advance – Omer Mar 03 '17 at 20:49
  • 1
    This is a bit old, but there's no need to subclass the invalidationContextForBoundsChange method, you could easily set the invalidated supplementary views in shouldInvalidateLayoutForBoundsChange. And there's no need to return the super implementation of shouldInvalidateLayoutForBoundsChange as the docs clearly say it just returns NO. – George Brown Oct 11 '17 at 16:52
  • @GeorgeBrown actually I think there may be an error in the original answer. You do not need to manually call `invalidationContext(forBoundsChange:)` at all. Simply returning 'yes' from `shouldInvalidateLayout(forBoundsChange:)` will cause it to be accessed automatically. – Ash Feb 19 '21 at 08:33
  • Actually, since no custom invalidation context is being used, I suspect that `invalidateEverything` is true. According to documentation, this would still cause every item to be refreshed. – Ash Feb 19 '21 at 08:41
7

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.

pkamb
  • 26,648
  • 20
  • 124
  • 157
Peeks
  • 151
  • 1
  • 7
4

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
Community
  • 1
  • 1
Benjohn
  • 12,147
  • 8
  • 58
  • 110
1

I implemented exactly what you are trying to do by setting a flag that tells me why prepareLayout is called, and then only recalculating the position of the sticky cells.

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
    _invalidatedBecauseOfBoundsChange = YES;
    return YES;
}

Then in prepareLayout I do:

if (!_invalidatedBecauseOfBoundsChange)
{
    [self calculateStickyCellsPositions];
}
_invalidateBecauseOfBoundsChange = NO;
Fabien Warniez
  • 2,654
  • 1
  • 19
  • 27
0

I'm working on custom UICollectionViewLayout subclass. I tried to use UICollectionViewLayoutInvalidationContext. When I update layout/view that is not need the entire UICollectionViewLayout to recalculated all of its attributes, I use my UICollectionViewLayoutInvalidationContext subclass in the invalidateLayoutWithContext: and in prepareLayout I recalculate only the attributes that is specified in my UICollectionViewLayoutInvalidationContext subclass properties instead of recalculating all attributes.