UICollectionViewCells Dynamic Width
Update (2022): The recommended approach for achieving this these days is via leveraging UICollectionLayoutListConfiguration
. A great reference is Apple’s Modern Collection Views sample code.
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.
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.
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.
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()
}
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.