This post is part of a series written by our developers that documents their journey in a cross-platform mobile app development project. They set out to improve an internal process, create educational content for the developer community, and explore new craft beers and a vintage arcade.

 

This is the third in a four-part series about doing custom layout for collection views in iOS using Swift.

All Parts of Series:
&nbsp

If you followed the walk-through in Part 2 of this series you’ll remember that we have a very basic collection view that displays project time entries. It uses a flow layout that puts time entries into rows. If there are more entries in a day than will fit on one row it simply overflows to a new row until all of the entries for that day are displayed.

Basic Collection View

Figure 1

It’s time to starting making this thing look good. In this article we will replace the built-in flow layout with a custom implementation. In order to keep things simple we won’t deal with scrolling yet. This will allow you to learn the fundamentals of custom layout quickly.

The source code for this article is available on Github.

UICollectionViewLayout

Custom layout is implemented by deriving a class from UICollectionViewLayout. Methods on this class will provide UICollectionView with the information it needs to position our time entry cells and header cells. For this project we’ll examine three important jobs of the layout object.

  1. Calculate the size of the content
  2. Calculate the size and position of cells
  3. Provide this information to UICollectionView when requested

To get started, add a new class that’s called TimeEntryCollectionLayout that inherits from UICollectionViewLayout. Then configure the UICollectionView to use the new layout object.

Note: Normally I would set the custom layout using the Attributes inspector on the collection view as seen in Figure 2. I found out the hard way that changing this clears the custom cells that were created in the previous article. Rather than start over I set the layout object in the collection view controller code.

CustomLayoutAttributes

Figure 2

class TimeEntryCollectionViewController: UICollectionViewController {
override func viewDidLoad() {
    let layout = TimeEntryCollectionLayout()
    layout.days = sampleDataByDay
    collectionView?.collectionViewLayout = layout
    collectionView?.scrollEnabled = false
}
...

If you run the code now you will find that nothing is rendered. This is because the collection view relies on the layout object to know where to place the cells.

Content Size

UICollectionView inherits from UIScrollView. A scroll view displays content that may be larger than the applications window. Figure three shows three possible scenarios for large content. The content may scroll vertically, horizontally, or both. I promised that we would keep things simple at first. To do that we’ll focus on a special case, one with no scrolling. Without scrolling the content is the same size as the visible area of the collection view.
LayoutContent

Figure 3

Calculate the Size and Position of Cells

Now we need to calculate the size and position of the cells. I find it helpful to sketch layouts like this before I start trying to write code. It helps me visualize what I need to know in order to do the calculations. Figure four shows a wireframe of the cell layout.

Each day will be represented by a single row. The header cell will be displayed on the left followed by zero or more time entry cells on the right. The width of the time entry cells will be proportional to the total number of hours logged for that day. If the number of hours in an entry is half of the day’s total, it will be half of the width.

Layout Diagram

Figure 4

We’ll form the dividers between the cells by simply letting the background of the collection view show through. Leaving space between the cells will do the job.

Class Members

The days property provides a reference to the data that we need for calculations. Any time the data changes the invalidateLayout method will be called. This marks the layout as invalid which will force prepareLayout to be called the next time layout information is needed.

I’ve also defined a few constants that define some basic information about the layout.

struct day {
    let date: String
    let entries: [entry]
}

struct entry {
    let client: String
    let hours: Int
}

class TimeEntryCollectionLayout: UICollectionViewLayout {
    private var cellAttributes = [NSIndexPath: UICollectionViewLayoutAttributes]()
    private var headerAttributes = [NSIndexPath: UICollectionViewLayoutAttributes]()
    
    private let numberOfVisibleDays = 7
    private let headerWidth = CGFloat(80)
    private let verticalDividerWidth = CGFloat(2)
    private let horizontalDividerHeight = CGFloat(2)
    
    var days: [day] = [] {
        didSet {
            invalidateLayout()
        }
    }

prepareLayout

The prepareLayout method is called early in the layout process. This method gives you a chance to do any calculations that you need. This is where most of the work will be done.

The layout object supplies the collection view with layout information using instances of the UICollectViewLayoutAttribute class. At a minimum, each layout attribute object contains the index path of a cell and its size and position in the content. Instances of this class can be created now, in prepareLayout, or later on demand. The decision is based on the cost of computing and caching the attribute objects. If you have many thousands of items it may be better to create them on demand. Even a year’s worth of time entries is unlikely to hold more than a couple of hundred cells so we’ll create them up front.

Now it’s time to do the real work. Before diving into the code it helps to map out the calculations in a little pseudo-code first.

  1. Clear the cache
  2. Calculate the height of a row
  3. Calculate the width available for time entry cells
  4. For each day
    1. Find the Y coordinate of the row
    2. Generate and store layout attributes header cell
    3. Get the total number of hours for the day
    4. For each time entry in day
      1. Get the width of the cell
      2. Find the X coordinate of the cell
      3. Generate and store layout attributes for the cell

With the logic mapped out the most of the actual code is straightforward. There are some things to note. Steps two and three allow for a content inset. The inset allows for proper spacing around things like the Status Bar. Without this our row height calculation would be wrong. Also, look closely at the layout attributes. Header cells are a special type of view called a Supplementary View. The initializer used for this type of attribute is different than the one used for content cells.

override func prepareLayout() {
    if (collectionView == nil) {
        return
    }
    
    // 1: Clear the cache
    cellAttributes.removeAll()
    headerAttributes.removeAll()
    
    // 2: Calculate the height of a row
    let availableHeight = collectionView!.bounds.height
        - collectionView!.contentInset.top
        - collectionView!.contentInset.bottom
        - CGFloat(numberOfVisibleDays - 1) * horizontalDividerHeight
    
    let rowHeight = availableHeight / CGFloat(numberOfVisibleDays)

    // 3: Calculate the width available for time entry cells
    let itemsWidth = collectionView!.bounds.width
        - collectionView!.contentInset.left
        - collectionView!.contentInset.right
        - headerWidth;
    
    // 4: For each day
    for (dayIndex, day) in days.enumerate() {
        
        // 4.1: Find the Y coordinate of the row
        let rowY = CGFloat(dayIndex) * (rowHeight + horizontalDividerHeight)
        
        // 4.2: Generate and store layout attributes header cell
        let headerIndexPath = NSIndexPath(forItem: 0, inSection: dayIndex)

        let headerCellAttributes =
            UICollectionViewLayoutAttributes(
                forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
                withIndexPath: headerIndexPath)
        
        headerAttributes[headerIndexPath] = headerCellAttributes

        headerCellAttributes.frame = CGRectMake(0, rowY, headerWidth, rowHeight)
        
        // 4.3: Get the total number of hours for the day
        let hoursInDay = day.entries.reduce(0) { (h, e) in h + e.hours }
        
        // Set the initial X position for time entry cells
        var cellX = headerWidth
        
        // 4.4: For each time entry in day
        for (entryIndex, entry) in day.entries.enumerate() {
            
            // 4.4.1: Get the width of the cell
            var cellWidth = CGFloat(Double(entry.hours) / Double(hoursInDay)) * itemsWidth
            
            // Leave some empty space to form the vertical divider
            cellWidth -= verticalDividerWidth
            cellX += verticalDividerWidth
            
            // 4.4.3: Generate and store layout attributes for the cell
            let cellIndexPath = NSIndexPath(forItem: entryIndex, inSection: dayIndex)
            let timeEntryCellAttributes =
                UICollectionViewLayoutAttributes(forCellWithIndexPath: cellIndexPath)
            
            cellAttributes[cellIndexPath] = timeEntryCellAttributes
            
            timeEntryCellAttributes.frame = CGRectMake(cellX, rowY, cellWidth, rowHeight)
            
            // Update cellX to the next starting position
            cellX += cellWidth
        }
    }
}

Provide Information to UICollectionView

Now that we have all of the layout information created we can provide it to the collection view. This is done by implementing four methods on the layout class. Since we aren’t allowing any scrolling this part will be very easy.

  • collectionViewContentSize
  • layoutAttributesForElementsInRect
  • layoutAttributesForItemAtIndexPath
  • layoutAttributesForSupplementaryViewOfKind

As we saw in Figure 3 the content size is the same as the size of the collection view.

override func collectionViewContentSize() -> CGSize {
    return collectionView!.bounds.size;
}

The collection view may ask for the layout attributes of an individual view. We already created and cached the attributes so all we have to do is return them.

override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) ->
    UICollectionViewLayoutAttributes? {
    return cellAttributes[indexPath]
}

override func layoutAttributesForSupplementaryViewOfKind(elementKind: String,
    atIndexPath indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
    return headerAttributes[indexPath]
}

The primary method used to get layout attributes is the layoutAttributesForElementsInRect method. This method asks for all layout attributes that interest a particular rectangle within the content. We’ll look at this method much closer in the next article. For now we can assume that all cells are always visible. We’ll just return information for all cells every time.

override func layoutAttributesForElementsInRect(rect: CGRect) ->
    [UICollectionViewLayoutAttributes]? {
    var attributes = [UICollectionViewLayoutAttributes](headerAttributes.values)
    attributes += [UICollectionViewLayoutAttributes](cellAttributes.values)
    return attributes
}

The Final Product

Everything we need is now in place. When you run the code you can see that we’ve transformed the simple flow layout into something very close to our ultimate goal. The next article will allow for scrolling and apply some final styling to complete the quest for an awesome time entry layout.
Custom Layout Part 3

Figure 5

All Parts of Series:
&nbsp