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 4th in a four-part series about doing custom layout for collection views in iOS using Swift.

All Parts of Series:
&nbsp

In the previous part of this series we built a custom layout for the time entry collection view. It was a simple layout that didn’t allow any scrolling. In this article the custom layout will be completed. The final styles will be applied and we’ll remove allow for scrolling content.

The source code for this article is available on Github.

Allow Scrolling

First we have to reenable scrolling on the collection view inside the TimeEntryCollectionViewController and add some more sample data. I’ve included two months worth of data so we can have plenty to scroll through.

let sampleDataByDay: [day] = [
    day(date: "Mon 8/3", entries: [entry(client: "Microsoft", hours: 8)]),
    day(date: "Tue 8/4", entries: [entry(client: "Google", hours: 2), entry
    // ...
    day(date: "Sat 9/26", entries: [entry(client: "Intertech", hours: 6)]),
    day(date: "Sun 9/27", entries: [])
]

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

Next we have to adjust the content size to allow for two month’s worth of rows. If you recall from the previous article the collection view will display only a portion of the actual content. In the case of the time view layout we will enable vertical scrolling. The width won’t change but the height will depend on the number of total days that are included in the data.

 

UICollectionView Vertical Scrolling

Figure 1

Let’s review the pseudo-code of the prepareLayout method from the previous article. Almost everything will remain the same other than adding one additional step.

  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
  5. Calculate and store the content size
override func prepareLayout() {
    // ...
    // Code Removed!
    // ...
    
    var rowY: CGFloat = 0
    
    // 4: For each day
    for (dayIndex, day) in days.enumerate() {
        
        // 4.1: Find the Y coordinate of the row
        rowY = CGFloat(dayIndex) * (rowHeight + horizontalDividerHeight)
        
        // ...
        // Code Removed!
        // ...
    }
    
    // 5: Store the complete content size
    let maxY = rowY + rowHeight
    contentSize = CGSizeMake(collectionView!.bounds.width, maxY)
}

override func collectionViewContentSize() -> CGSize {
    if contentSize != nil {
        return contentSize!
    }
    return CGSize.zero
}

If you run the code now you will see that it scrolls through all of the time entries. So we’re done right? Not exactly.

The layout class is requesting attributes using the layoutAttributesForElementsInRect method. This method asks for layout information for any cells that intersect a particular rectangle. Right now we are returning attributes for all cells every time. If we add a couple of print statements we can get an idea of what’s going on.

override func prepareLayout() {
    // ...
    print("collectionView size = \(NSStringFromCGSize(collectionView!.bounds.size))")
    print("contentSize = \(NSStringFromCGSize(contentSize!))")
}

override func layoutAttributesForElementsInRect(rect: CGRect) ->
    [UICollectionViewLayoutAttributes]? {
    // ...
    print("layoutAttributesForElementsInRect rect = \(NSStringFromCGRect(rect)), returned \(attributes.count) attributes")
    return attributes
}

The first three line are output immediately as the collection view is displayed. Notice that the rectangle that is being requested is not actually the visible area. It actually extends above the content area using a negative height.  As I scrolled down to the bottom you can see the additional rectangles that were requested.

collectionView size = {414, 736}
contentSize = {414, 5742}
layoutAttributesForElementsInRect rect = {{0, -736}, {414, 1472}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 736}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 1472}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 2208}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 2944}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 3680}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 4416}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 5152}, {414, 736}}, returned 120 attributes
layoutAttributesForElementsInRect rect = {{0, 5888}, {414, 736}}, returned 120 attributes

In order to make our scrolling efficient we need to return only the required attributes for a particular rectangle. You can use any technique you wish to determine which attributes should be returned.

override func layoutAttributesForElementsInRect(rect: CGRect) ->
    [UICollectionViewLayoutAttributes]? {
    
    var attributes = [UICollectionViewLayoutAttributes]()

    for attribute in headerAttributes.values {
        if CGRectIntersectsRect(attribute.frame, rect) {
            attributes.append(attribute)
        }
    }

    for attribute in cellAttributes.values {
        if CGRectIntersectsRect(attribute.frame, rect) {
            attributes.append(attribute)
        }
    }
        
    print("layoutAttributesForElementsInRect rect = \(NSStringFromCGRect(rect)), returned \(attributes.count) attributes")
    
    return attributes

}

Now the output shows a significantly different outcome. Only the attributes within the requested rectangle are being returned. The larger your data, the more this will matter. Nobody likes a sluggish scroll.

collectionView size = {414, 736}
contentSize = {414, 5742}
layoutAttributesForElementsInRect rect = {{0, -736}, {414, 1472}}, returned 17 attributes
layoutAttributesForElementsInRect rect = {{0, 736}, {414, 736}}, returned 17 attributes
layoutAttributesForElementsInRect rect = {{0, 1472}, {414, 736}}, returned 17 attributes
layoutAttributesForElementsInRect rect = {{0, 2208}, {414, 736}}, returned 17 attributes
layoutAttributesForElementsInRect rect = {{0, 2944}, {414, 736}}, returned 17 attributes
layoutAttributesForElementsInRect rect = {{0, 3680}, {414, 736}}, returned 20 attributes
layoutAttributesForElementsInRect rect = {{0, 4416}, {414, 736}}, returned 18 attributes
layoutAttributesForElementsInRect rect = {{0, 5152}, {414, 736}}, returned 13 attributes
layoutAttributesForElementsInRect rect = {{0, 5888}, {414, 736}}, returned 0 attributes

The custom layout is now complete.

Complete the Styling

We can wrap this up by doing some final adjustments to the reusable views in the UICollectionView.

  • Font size is reduced in the cell labels
  • Font size is reduced in the header labels
  • Background color of the two headers labels are made the same
  • Background color of the UICollectionView is set
  • Header labels are modified to allow two lines of text
  • Text of the hours label is modified to include multiple fonts
override func collectionView(collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, atIndexPath indexPath: NSIndexPath) -> UICollectionReusableView {
    let cell = collectionView.dequeueReusableSupplementaryViewOfKind(kind, withReuseIdentifier: "dayHeaderCell", forIndexPath: indexPath) ;
    
    let day = sampleDataByDay[indexPath.section];
    let totalHours = day.entries.reduce(0) {(total, entry) in total + entry.hours}
    
    let dateLabel = cell.viewWithTag(1) as! UILabel
    let hoursLabel = cell.viewWithTag(2) as! UILabel
    
    dateLabel.text = day.date.stringByReplacingOccurrencesOfString(" ", withString: "\n").uppercaseString
    hoursLabel.text = String(totalHours)
    
    let hours = String(totalHours)
    let bold = [NSFontAttributeName: UIFont.boldSystemFontOfSize(16)]
    let text = NSMutableAttributedString(string: "\(hours)\nHOURS")
    text.setAttributes(bold, range: NSMakeRange(0, hours.characters.count))
    hoursLabel.attributedText = text
    
    return cell
}

The only changes to the code take place in the collection view controller that was created in the first article of the series.

The Final Product

The final product is finally here. We’ve come a long way from the first implementation. I hope this series has given you some big ideas on using UICollectionView in your projects. Whether it’s using a basic flow layout or a complete custom layout, the collection view is a powerful tool to have in the box.

FinalProduct

Figure 2

Additional Resources

Collection View Programming Guide for iOS

UICollectionView Class Reference

UICollectionView Class Reference

All Parts of Series:
&nbsp