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