Overview

UITableViews are a popular choice when it comes to implementing just about any kind of screen for your app. UICollectionViews are even more powerfull and offer a wider range of options. You can find out more about collection views by reading Apple’s documentation, watching any of their WWDC videos on the topic, or reading this great post on NSHipster.

While collection views offer a great deal of flexibility and customisation - they don’t offer the same pre-canned behaviours we all take for granted in table views. One of these items is the built in editing mode which allows user interaction to re-order and delete items from the list.

UITableView Editing

While it is possible to implement this with collection views - we’d have to do a lot of the heavy lifting ourselves to achieve it. Another item which may seem quite trivial is the automatic width sizing table views apply to their cells so they span across the whole table view.

UITableView Portrait

UITableView Landscape

In this post I wanted to share how to achieve that behaviour with collection views. I will walkthrough some of the options available when using Flow Layout.

Option 1: The simple option

We can simply set the itemSize width on Flow Layout to be the same as the collection view width.

import Foundation
import UIKit

class ItemSizeCollectionViewcontroller : UICollectionViewController
{
    // MARK: Outlets

    @IBOutlet weak var flowLayout: UICollectionViewFlowLayout!

    // MARK: Properties

    var dataSource = CommonDataSource()

    // MARK: UIViewController

    override func viewDidLoad()
    {
        super.viewDidLoad()

        let nib = UINib(nibName: "MyCollectionViewCell", bundle: NSBundle.mainBundle())

        // Manually set the size to be the same as the collection view width
        // flowLayout.itemSize = CGSize(width: collectionView!.bounds.width, height: 60)

        // For completeness the section insets need to be accommodated
        var width = collectionView!.bounds.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right
        flowLayout.itemSize = CGSize(width: width, height: 60)

        collectionView?.registerNib(nib, forCellWithReuseIdentifier: "cell")
        collectionView?.dataSource = dataSource
    }

    // MARK: Actions

    @IBAction func didTapRefresh(sender: UIBarButtonItem)
    {
        collectionView?.reloadData()
    }
}

This does work but has a few caveats. First, section insets need to be accounted for and second this will not work as soon the device is rotated to landscape.

UICollectionView Portrait

UICollectionView Landscape

Attempting to reload data won’t cut it unless we also set the item size before reloading … not a great option.

Option 2: Flow Layout’s Delegate

To mitigate the issues stated above we can make use of the delegate method, that way on reloads the item size will automatically get picked up.

// MARK: UICollectionViewDelegateFlowLayout

func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
    var width = collectionView.bounds.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right
    return CGSize(width: width, height:60)
}

This still won’t solve the issue of re-sizing on rotation without requiring a call to reload data.

Option 3: Reacting to Rotations

In iOS 8, the willRotateToInterfaceOrientation(...) and didRotateFromInterfaceOrientation(...) methods on UIViewController were deprecated in favor of viewWillTransitionToSize(...).

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
    super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)

    collectionView?.reloadData()
}

UICollectionView Landscape

This will do the trick … but perhaps reloading data may not be desirable - after all the data didn’t change, all we want to do is stretch the cells that are already there. UICollectionViewLayout has an invalidateLayout() method that can be used to achieve that.

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
    super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)

    flowLayout.invalidateLayout()
}

This sadly won’t work. At the point we call invalidate layout above, the collection view has not yet been resized to its new size. One way around that is to perform the invalidate layout call after the transition completes.

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
    super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)

    coordinator.animateAlongsideTransition({ (context) -> Void in
    }, completion: { (context) -> Void in
        self.flowLayout.invalidateLayout()
    })
}

This will kinda work … but doesn’t seem very elegant. A better way to do this is to temporarily store the new size and work out the collection view size from it.

var widthToUse : CGFloat?

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
    super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)

    widthToUse = size.width
    flowLayout.invalidateLayout()
}

func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
    var collectionViewWidth = collectionView.bounds.width
    if let w = widthToUse
    {
        collectionViewWidth = w
    }
    var width = collectionViewWidth - flowLayout.sectionInset.left - flowLayout.sectionInset.right
    return CGSize(width: width, height:60)
}

This will work as intended. Issues will arise when the collection view is part of a more elaborate screen with other views surrounding it and as such would require duplicating layout logic (or worse writing new layout logic which auto-layout was already taking care of).

Option 4: Flow Layout Subclass - modifying attributes

While the previous solutions do work and can probably be improved or tweaked further. Personally however, I found the following solution to be the best. Creating a subclass of flow layout which works out the width automatically.

class FullWidthCellFlowLayout : UICollectionViewFlowLayout
{
    func fullWidth() -> CGFloat {
        let bounds = collectionView!.bounds
        let contentInsets = collectionView!.contentInset

        return bounds.width - sectionInset.left - sectionInset.right - contentInsets.left - contentInsets.right
    }

    // MARK: Overrides

    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var attributes = super.layoutAttributesForElementsInRect(rect)
        if let attrs = attributes {
            attributes = updatedAttributes(attrs)
        }
        return attributes
    }

    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
        var attributes = super.layoutAttributesForItemAtIndexPath(indexPath)
        if let attrs = attributes {
            attributes = updatedAttributes(attrs)
        }
        return attributes
    }

    // MARK: Private

    private func updatedAttributes(attributes: [UICollectionViewLayoutAttributes]) -> [UICollectionViewLayoutAttributes] {
        return attributes.map({ updatedAttributes($0) })
    }

    private func updatedAttributes(originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        let attributes = originalAttributes.copy() as! UICollectionViewLayoutAttributes
        attributes.frame.size.width = fullWidth()
        attributes.frame.origin.x = 0
        return attributes
    }
}

Update (7th-Feb-2016): for this to work reliably one extra addition is needed

In the UICollectionViewDelegateFlowLayout we need to also specify the width

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
    {
        return CGSize(width: fullWidthLayout.fullWidth(), height:60)
    }

Option 5: Flow Layout Subclass - modifying item size

Update (7th-Feb-2016): Revisiting this post, I have found a simpler and more reliable solution

Looking back at Option 4 that solution now no longer seems as elegant as it could be. Following the same approach with a flow layout subclass, a simpler solution can be devised. We can update the itemSize’s width property on any bounds change and in prepareLayout to make it work on first load.

class FullWidthCellsFlowLayout : UICollectionViewFlowLayout {

    func fullWidth(forBounds bounds:CGRect) -> CGFloat {

        let contentInsets = self.collectionView!.contentInset

        return bounds.width - sectionInset.left - sectionInset.right - contentInsets.left - contentInsets.right
    }

    // MARK: Overrides

    override func prepareLayout() {
        itemSize.width = fullWidth(forBounds: collectionView!.bounds)
        super.prepareLayout()
    }

    override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
        if !CGSizeEqualToSize(newBounds.size, collectionView!.bounds.size) {
            itemSize.width = fullWidth(forBounds: newBounds)
            return true
        }
        return false
    }
}

I have also added a demo project on github to showcase this solution.