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
?
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:
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:
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).
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 abackgroundView
property.I create the background view in my
UICollectionViewController
'sviewDidLoad
method. By this point the view controller already has aUICollectionView
instance in itscollectionView
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 thebackgroundView
property of the foreground collection view. I set up the background to also use theUICollectionViewController
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: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:
The
registerForReuseInView:
method is something added toUICollectionReusableView
by a category, along withdequeueFromView:
. 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 thatUICollectionView
will clamp thecontentOffset
when it is programatically set, such that the content does not tear away from theUICollectionView
's bounds. Without a remedy, only the foreground sticky elements will bounce, which looks horrible. However, adding the following to yourviewDidLoad
will fix this: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:
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:
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:In my
UICollectionViewLayout
subclass, when layout attributes are asked for, I set up the z property to meet the ordering like this: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: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:
However, they have difference cell counts in the sections, but this is easy to accommodate
My
UICollectionViewLayout
subclass also has some view dependent methods it delegates to the UICollectionViewController subclass, but these are easily handled using the pattern above:As a sanity check, I ensure the collection views only ask for cells from the sections that they should be displaying:
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 categoryIt's implementation is:
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 myUICollectionViewLayoutInvalidationContext
subclass in theinvalidateLayoutWithContext:
and inprepareLayout
I recalculate only the attributes that is specified in myUICollectionViewLayoutInvalidationContext
subclass properties instead of recalculating all attributes.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.
Then in
prepareLayout
I do: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 itCustomInvalidationContext
) 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 ofCustomInvalidationContext
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.