Many iOS developers think of UICollectionView
as UITableView
with a grid. But with a little math and a lot of creativity, UICollectionView
can be used to render timelines, charts and graphs, parallax scrolling landscapes and more. Through working examples, Nathan Eror shows just how flexible and powerful this API really is.
Introduction (0:00)
Today, I’m going to try to give you an overview of how you would go about implementing custom collection view layouts. The reason I named this talk “Beyond the Grid” is because usually when the subject of collection views comes up with iOS developers, the perception is that they are used when you need a table view that is a grid. While you can use it that way, that isn’t necessarily the only thing you can do.
UICollectionView (0:50)
The UICollectionView
class is actually tremendously flexible and powerful. It’s the first API that I go to whenever I have a large layout defined by a known structure, but an unknown amount of data. Instead of implementing a scroll view myself, I can implement a collection view. You get the full flexibility you would get from using something like UIScrollView
and putting views in it yourself, as well as other advantages and nice API help from UICollectionView
.
The reason that collection views are often thought of as grids is because of this: UICollectionViewFlowLayout
. You’ve probably seen if it if you’ve tried to make a collection view. This is the only concrete subclass of UICollectionViewLayout
that actually ships with iOS. But UICollectionViewLayout
is an abstract base class that you can extend yourself to generate the layout. So, this is basically the data model, or the view model for what shows up in the collection. The CollectionViewLayout is responsible for providing what items are in the collection and what needs to be rendered in what areas of the scroll view. The collection view is itself just a scroll view.
Example: Timeline (2:32)
Here are some examples of things that aren’t grids, but can still be implemented with UICollectionView
. This is a timeline, entirely implemented with this. There are cells, accessory views, and decoration views in here. Because it’s timeline data, it just has ranges of days. It also needs to be scrollable for as much data there is. You can zoom and pinch, and all the movements are smooth as butter. The pinching and zooming actually took only two lines of code to implement, because I used CollectionViewLayout. The dates at the top are just the accessory views, and the little dots are actually the collection view cells. Everything else is an accessory view.
Let’s Build This: Schematic (3:59)
Now I’m going to go over how you do this kind of thing. I’ve built a demo of something different that we can build with a collection view. You might be able to accomplish this using a flow layout, but it would be pretty difficult. It’s not all that difficult using a custom collection view layout.
Before we get going, I want to note a few things. Each of these little squares is a collection view cell. Those lines are accessory views, which we’ll talk about in a second. You might also notice that each of these little boxes is actually sized based on the size of its label.
So, the whole layout is dynamically generated and updated based on the data. There are different features of UICollectionView
that we use to implement this, including the things new in iOS 7. This includes CollectionViewLayout
, validation, and self-sizing cells. That last one is a really powerful addition that has made UICollectionView
a lot easier to use, as well as very large. It still has pretty good performance, too.
UICollectionViewLayout Methods (5:01)
So you want to create your own UICollectionViewLayout
, and you go and look at the documentation. These are some of the methods, though not all of them are required. However, the following three are required to be overridden in any concrete subclass of UICollectionViewLayout
.
collectionViewContentSize()
layoutAttributesForElementsInRect(_ rect: CGRect)
layoutAttributesForItemAtIndexPath(_ indexPath: NSIndexPath)
Two other methods, layoutAttributesForSupplementaryViewOfKind
and layoutAttributesForDecorationViewOfKind
, are both optional. The reason for that is you don’t have to have supplementary and decoration views. The difference between ItemAtIndexPath
, which would be UICollectionViewCell
subclass, and the other two, is that the cell is the important piece of data. That is the item that your data is meant to represent.
In the timeline example, the little dot represented that day on the timeline. In the example of that schematic, it was the box that had the string in it. Supplementary views are still related to the data. They have some relation to the data, but they don’t display the data. In the case of the timeline view, they’re an extra bit of addition to the data; those were the lines between the dots. We also had the headings with the dates in them. It’s all driven by the data, but isn’t the main focus of the data.
Decoration views are a little different, because they aren’t driven by the data at all. Those are like adornments that can be added to collection views. These can be things like a legend, where they don’t necessarily relate to the data at all. It’s something that I don’t use very often, but there are use cases for it. I use supplementary views and the main cells in pretty much all of the collection view layouts that I build.
Architecture (7:25)
How do we go about building a collection view? The key piece of building one of these layouts, while doing it fairly quickly and well organized, is to have a decent architecture. I just deal with the NSFetchedResultsController
kind of architecture for my collection view layouts. So, the example that we’re doing again was the schematic you saw. The view controller can be subclass of UICollectionView
controller because all the work is done in the collection view. This does give you all the same kind of benefits that sub-classing UITableViewController
does, though it also gives you some of the negatives as well.
Your view controller obviously has to have a reference to the collection view. It’s the main view of the view controller, and the collection view has a delegate and a data source just like a table view. That provides the data and the other information to be displayed to the collection view. This gives you the ability to configure the cells and the supplementary views.
The layout is the piece that’s different. The collection view has a reference to a layout, and the layout is what the collection view speaks to when it asks where things go. Then they speak back and forth to provide the information to render the final layout. The collection view is responsible for doing things like cell reuse and handling everything related to the scroll view, and it just asks the layout for information. The layout is the view model for the collection view.
The piece of architecture that isn’t part of the standard pieces of UICollectionView
is the data controller. This extra piece makes the code a lot more organized and easier to deal with. What I usually call a data controller is something like an NSFetchedResultsController
. It is responsible for gathering the data and holding it in some kind of internal data structure, so that the layout has all the information is needs to generate all the frames and bounds. It’s also useful to the delegate and the data source, because the delegate and the data source have to configure the cells and the supplementary views. The data controller is called by all three of these to provide information.
Model (10:15)
In the schematic example we looked at, I have a pretty simple model. It has a name that is a string, and an enum whose cases just provide a color for the UI. It’s a tree, so it has a parent node and a set of child nodes. That’s the entire model for this app.
class Node: Hashable, Equatable, Printable {
let name: String
enum NodeType: Printable {
case Normal
case Important
case Critical
var nodeColors: (label:UIColor, background:UIColor) {
switch(self) {
case .Normal:
return (label:UIColor.blackColor(), background:UIColor.lightGrayColor())
case .Important:
return (label:UIColor.blackColor(), background:UIColor.yellowColor())
case .Critical:
return (label:UIColor.whiteColor(), background:UIColor.redColor())
}
}
var description: String {
switch(self) {
case .Normal:
return "Normal"
case .Important:
return "Important"
case .Critical:
return "Critical"
}
}
}
let nodeType: NodeType
var parent: Node?
var children = Set<Node>()
}
Data Controller (10:47)
Like I said earlier, the data controller is the piece that is like the NSFetchedResultsController
for this architecture. I use some of the same method names as well, so we can performFetch
as if this were a Core Data based app. performFetch
would go and fetch the data store, store everything in some local data structure that’s fast and easy to access, and then go on from there.
In this demo, I’m just generating a bunch of static nodes. This is fairly straightforward - it’s just generating the same static tree every time you launch the app. The rest of the public API has two methods: nodeAtIndexPath
and indexPathForNode
.
These are used by the layout, the data source, and the delegate to configure everything in the collection view. There is also an important cacheSections
method. If you look at the properties at the top, you’ll see that sections
is a two-dimensional array. That is basically the in-memory data store for all of the nodes that we’ve fetched from the data store. The secions are what is being provided, so cacheSections
takes all of the nodes in the set and organizes them into sections - the columns that you saw in the UI.
Layout Model (12:37)
The layout itself has its own data structure to manage the layout. This struct is where all of the information that the layout needs to generate a layout is stored, and is actually in line with the layout model in the code. There is an offset
for each section, and I split the sections into columns. Each instance of the SectionDescription
struct will describe a column as an array of items, and then it does calculations to figure out where each piece goes in the layout.
struct SectionDescription {
typealias Item = (size: CGSize, parents: NSIndexSet)
var offset: CGFloat
var size: CGSize = CGSizeZero
var itemPadding: CGFloat = 40
var items: [Item] {
didSet {
self.recalculateSize()
}
}
...
}
The item array, which contains a tuple of the size and an index set of parents, is really just going to be an index in this case. That’s all the information that I need in the layout to provide everything, from frames to sizes, to the layout, data source, and delegate.
Layout (14:45)
What do we do after we have all of our data organized? Remember that I said there are some methods you have to override, if you want to create your own collection view layout. The first two are prepareLayout
and collectionViewContentSize.
collectionViewContentSize
gives you the content size of the collection view. One of the reasons I have to cache all of the layout information in that data structure is because I need to be able to generate a content size for the scroll view. I used the offset
and the size
properties of the SectionDescription
to generate the content size. It’s pretty straightforward to just figure out the max width and the max height, or use the number of columns and their width to then figure out the max height of the biggest column.
override func collectionViewContentSize() -> CGSize {
if let dataController = self.dataController, sectionDescriptions = self.sectionDescriptions {
var width: CGFloat = 0.0
if let lastSection = sectionDescriptions.last {
width = lastSection.maxX + self.sectionMargin
}
let height = reduce(sectionDescriptions, 0.0) {
max($0, $1.size.height)
}
return CGSize(width: width, height: height)
}
returnsuper.collectionViewContentSize()
}
The next thing that will be called when it’s time to make a layout is prepareLayout
. All this is doing is setting up all the data structure. This is where we make the SectionDescription
struct go into an array in my Layout class.
override func prepareLayout() {
if self.sectionDescriptions == nil {
self.sectionDescriptions = [SectionDescription]()
if let dataController = self.dataController {
for (sectionIndex, nodes) in enumerate(dataController.sections) {
let sectionIndexFloat = CGFloat(sectionIndex)
let offset = sectionMargin + (sectionIndexFloat * self.nodeSize.width) + (sectionIndexFloat * self.sectionPadding)
var sectionInfo = SectionDescription(offset: offset, items: map(nodes) {
node in
var indexSet = NSMutableIndexSet()
if sectionIndex > 0 {
if let parent = node.parent, indexPath = dataController.indexPathForNode(parent)
where indexPath.section == sectionIndex - 1 {
indexSet.addIndex(indexPath.item)
}
}
return (size: self.nodeSize, parents: indexSet)
})
self.sectionDescriptions!.append(sectionInfo)
}
}
}
}
prepareLayout
will get called anytime that the collection view needs to relay out the cells in all the other views in the collection view. In order to only have it called once, I just cache it all in memory. This isn’t that big of a diagram, so I don’t need to do anything special. I just have the sectionDescriptions
array as an optional, and set it to nil. The first time prepareLayout
is called, everything is set up and the array is generated. Every other time that prepareLayout
might be called, it will just be ignored because the data is static.
In your layouts, the data might change. You might have to do some extra work to prepare your data structure, so that all the other methods can call into it and get the layout. If that were the case, you’d do something different here, but in this case, it only gets called one time for whenever the section description is set up. These are the two most important methods; if you don’t implement them, nothing will happen.
Layout Cont. (17:01)
The other methods that you have to implement, if you want things to happen, are layoutAttributesForElementsInRect
and layoutAttributesForItemAtIndexPath
.
The documentation for the first provides instances of UICollectionViewLayout
attributes. Unfortunately, that is Objective-C and not Swift. That is a value object that contains information like Frame
, Transform
, Size
, and everything you would use to position the view in a scroll view. All we do with layoutAttributesForElementsInRect
is when the collection view has a rectangle and needs to know what fits in it. It can also do cell reuse, for instances like when it’s bigger than the actual viewable rect.
You’ll notice that we use layoutAttributesForItemAtIndexPath
even in the previous method. Since we already have our data structure, that data structure basically caches all the layout information in advance. All I’m doing is calling into that. If you remember, that struct had a method on it, frameForItemAtIndex
that generates a frame. Here I set the layout attribute’s Frame
property to that and return it.
Collection Data Source (18:52)
The collection view will then call the collection view’s data source and the cellForItemAtIndexPath
method. These are the collection view data source methods, which should look very familiar if you’ve ever done a table view or a collection view: numberOfSectionsInCollectionView
and collectionViewNumberOfItemsInSection
. All I’m doing is calling the data controller, which is very similar to how you use NSFetchedResultsController
.
The method cellForItemAtIndexPath
is called multiple times for all the items, and then the collection view goes and actually instantiates instances of UICollectionViewCell
, or whatever subclass you need. Then the cells have been created, and the frame that we set is now in there, being used for that cell.
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return self.dataController?.sections.count ?? 0
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.dataController?.sections[section].count ?? 0
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(SchematicNodeCell.cellReuseIdentifier, forIndexPath: indexPath) as! SchematicNodeCell
if let node = self.dataController?.nodeAtIndexPath(indexPath) {
cell.nameLabel.text = node.name
cell.nameLabel.textColor = node.nodeType.nodeColors.label
cell.containerView.backgroundColor = node.nodeType.nodeColors.background
}
return cell
}
Layout (19:48)
Next, we need to add those lines of supplementary views. All I do in my layoutAttributesForElementsInRect
method is add this section here, which will generate the layoutAttributesForSupplementaryViewOfKind
:
let nextSectionIndex = sectionIndex + 1
if let nextSection = sectionDescriptions.optionalElementAtIndex(nextSectionIndex) {
let children = filter(enumerate(nextSection.items)) { childIndex, child in child.parents.containsIndex(itemIndex) }
for (childIndex, child) in children {
itemAttributes.append(self.layoutAttributesForSupplementaryViewOfKind(SchematicLayout.connectorViewKind, atIndexPath: NSIndexPath(forItem: childIndex, inSection: nextSectionIndex)))
}
}
Supplementary views can be of multiple kinds. And so, that method is layoutAttributesForSupplementaryViewOfKind
, which provides the layout attributes for the particular line that is between each of those cells. All I do is add those lines to layoutAttributesForElementsInRect
. This is the string that defines what kind of class to use. The supplementary views are subclasses of UICollectionReusableView
.
The method supplementaryViewOfKind
generates a rectangle that has a line drawn diagonally, from corner to corner. We’re just doing straight lines from one spot to another, so it’s pretty straightforward. When the line goes in a different direction, I use a boolean that I can just flip.
Now I need that information in my layout attributes, so one thing I can do is subclass UICollectionViewLayoutAttributes
and add any other attributes you might need for your layout to generate its layout. So, I just add this extra boolean to have connector lines start at the top or at the bottom.
class SchematicLayoutAttributes: UICollectionViewLayoutAttributes, NSCopying {
var connectorLineStartTop: Bool = true
override func copyWithZone(zone: NSZone) -> AnyObject {
var copy = super.copyWithZone(zone) as! SchematicLayoutAttributes
copy.connectorLineStartTop = self.connectorLineStartTop
return copy
}
}
Connector View (21:53)
Inside the class ConnectorView
, which is a subclass of UICollectionReusableView
, we have the method applyLayoutAttributes
. The UICollectionViewCell
and UICollectionReusableView
subclasses can override this method. It gets called with that layoutAttributes
object, and that allows you to set anything inside of the connector view or the cell.
override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes!) {
super.applyLayoutAttributes(layoutAttributes)
if let attributes = layoutAttributes as ? SchematicLayoutAttributes {
self.lineStartTop = attributes.connectorLineStartTop
}
}
Collection Data Source (22:20)
The data source is just one line of code. Earlier, in the viewDidLoad
method, I set up the reuse identifier and mapped it to the class. Then it just takes one line to dequeue the cell and return it.
override func collectionView(collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, atIndexPath indexPath: NSIndexPath) ->
UICollectionReusableView {
return self.collectionView!.dequeueReusableSupplementaryViewOfKind(kind, withReuseIdentifier: SchematicLayout.connectorViewKind, forIndexPath: indexPath) as! UICollectionReusableView
}
Invalidation (22:46)
The last piece involves the new stuff from iOS 7, and that’s invalidation. I’m going to go through this pretty quickly, but it’s pretty powerful. Invalidation allows you to invalidate pieces of your layout, and provide new attributes for those pieces of the layout. You can invalidate the layout from your own code, or the layout can be invalidated by other pieces of code as well. In the case of self-sizing cells, which we’re using here, the UITableViewCell
is given a chance to say, “I want to be this size, not the size you told me.” When that happens, it calls the layout invalidation methods,and allows you to redo your layout based on the size the cell wants to be.
When you’re doing invalidation, there are a few things you need to override, including shouldInvalidateLayoutForBoundsChange
. You would do that if you wanted to do things like have the bounds change when the view scrolls. It would call in every frame as you’re scrolling. But for preferred layout attributes, override shouldInvalidateLayoutForPreferredLayoutAttributes
. In the case of the collection view self-sizing cells, the collection view generates a new instance of CollectionViewLayoutAttributes
. It then hands you original size you sent and the size that the cell would prefer to use, and allows you to choose which to go with.
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return false
}
override func shouldInvalidateLayoutForPreferredLayoutAttributes(preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes:
UICollectionViewLayoutAttributes) -> Bool {
if originalAttributes.representedElementCategory == .Cell {
return !CGSizeEqualToSize(originalAttributes.size, preferredAttributes.size)
}
return false
}
The method preferredLayoutAttributesFittingAttributes
is actually called on the collection view cell. It’s implemented by default, you don’t have to override it unless you’re creating some crazy subclass that does something differently.
func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes!
UICollectionViewLayoutInvalidationContext
is the information about the invalidation. The context is the information about everything that has changed and has made the layout invalid. You can also subclass it and customize it yourself to add more information. In this case, I’ve added invalidateSizesForIndexPaths
, which just allows me to easily map that this index path’s size is different.
class SchematicLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {
var invalidatedSizesForIndexPaths: [NSIndexPath:CGSize]?
func invalidateSize(size: CGSize, forIndexPath indexPath: NSIndexPath) {
if self.invalidatedSizesForIndexPaths == nil {
self.invalidatedSizesForIndexPaths = [NSIndexPath:CGSize]()
}
self.invalidatedSizesForIndexPaths![indexPath] = size
}
}
For every layout attributes that is invalidated, invalidateContextForPreferredLayoutAttributes
is called on your layout. This gives you the opportunity to decide what to do with it, and to change your internal data model with all of the actual layout information and match what the preferred layout is if you want to. It takes what the size the cell said it wanted to be, goes over all the cells that it might be connected to, and then re-sizes them and moves them and the connector lines if necessary.
Finally, invalidateLayoutWithContext
is called. All I’m doing here is looping through all the information, and updating my internal data structure for the new layout. I’m changing things like the sizes, the widths of columns, the offsets of the columns, and the sizes of the cells inside the columns. Once all that’s done, you have to call super
at the end. It’s in the documentaiton, so please do it.
There’s also invalidateEverything
, where I would set the descriptions to nil and just start from scratch. But usually, in the case of self-sizing cells, we’re going to have this custom property that we set up that has the dictionary, and maps index paths to new sizes. That’s what I’m using to set the data structure.
override func invalidateLayoutWithContext(context: UICollectionViewLayoutInvalidationContext) {
if let context = context as ? SchematicLayoutInvalidationContext, invalidatedSizesForIndexPaths = context.invalidatedSizesForIndexPaths where self.sectionDescriptions != nil {
var firstSection = self.sectionDescriptions!.count
for (indexPath, size) in invalidatedSizesForIndexPaths {
var item = self.sectionDescriptions![indexPath.section].items[indexPath.item]
item.size = size
self.sectionDescriptions![indexPath.section].items[indexPath.item] = item
firstSection = min(firstSection, indexPath.section)
}
self.recalculateSectionSizesInRange(firstSection.. < self.sectionDescriptions!.count)
}
if context.invalidateEverything {
self.sectionDescriptions = nil
}
super.invalidateLayoutWithContext(context)
}
Once that’s all done, we finally hit the final thing. It would infinitely scroll and uses cell reuse, so the cells that are on the screen aren’t actually instantiated. If we were going down 20 levels deep, we would only instantiate as many cells as needed. But because of the the layout calculations are cached in that internal data structure, the calculations to generate the layout aren’t all that complicated. Now we have cells that are sized by their content, which allows us to use auto-layout even though we’re creating a lot of static frames. We could still use a layout inside of our cells to get the cell sizes. These cells are sized based on the auto-layout that is set with the label inside of the view, and then everything is laid out.
Q&A (29:02)
Q: Can you quickly show share how you implemented pinching and scrolling?
Nathan: Each of my layouts has that internal data structure, where everything is pre-cached. In the case of that particular collection view, everything was cached inside of a data structure. It’s a timeline, so the internal data structure just has a bunch of ranges. I have an extra property on the layout that is a scale factor, which is applied to the ranges when I calculate the frames for all of the layout. As you’re pinching, I create a new instance of the layout with a different scale factor, for each callback of the pinch gesture recognizer. I pulled out the data that describes all the layout infornmation into its own class, so that I can pass that around between all the objects. In the collection view, you call collectionViewSetLayout
to give it the new layout, and it just updates everything.
I also added a two finger tap gesture recognizer that would get the timeline back to the original scale. I did that the same way: create a new instance of the layout, pass it to the data structure instance, and just let it use the default scale. Then I call collectionViewSetLayout
, and I noticed there’s an animated flag you can send as well, so I set that to true
and just animated it back to the original scale. You can also do a lot of things, like animations and transitions between layouts. All you do is hand off different layouts to the collection view.
Q: Could you say more about what causes you to use decoration or supplementary views?
Nathan: I kind of go by the documentation. Supplementary views are something that depends on what’s in the data. So, it depends on the same data used to layout the cells, which in this case, are the nodes. Each of those lines depends on how many nodes there are, how they’re connected, and where they are in the tree. That is the perfect case for a supplementary view.
A decoration view is something that isn’t tied to the data at all. In the collection view data source delegate, there’s a cellForitemAtIndexPath
and viewForSupplementaryView
. You can get the view and configure it. There’s nothing like that for the supplementary view. The supplementary view is purely part of the layout. You can provide the layout information to do things with it, but it isn’t driven by the data in any way, and it’s not part of the collection view data source or anything like that.
You would only use a decoration view for visual adornments. Maybe your entire collection view is surrounded by something like a picture frame; the frame would be a decoration view. I haven’t really used them for much. The reason I use collection views is because all of the views that I want to put on the screen are driven by the data.
Q: Sometimes I use a collection view because I’m lazy and that lets you scale out to an iPad. Do you have any comments on that?
Nathan: I have done it before. I have had requirements where the view was very different with different frames, like in different orientations. But you lose some of the benefits of table view. You lose editing, like the little swipe to delete cell. You lose all of that stuff you get with the table. If you don’t need those things, then the collection view the same thing. You could implement UITableView
with UICollectionView
pretty easily.
Receive news and updates from Realm straight to your inbox