223
votes

I'm trying to get self sizing UICollectionViewCells working with Auto Layout, but I can't seem to get the cells to size themselves to the content. I'm having trouble understanding how the cell's size is updated from the contents of what's inside the cell's contentView.

Here's the setup I've tried:

  • Custom UICollectionViewCell with a UITextView in its contentView.
  • Scrolling for the UITextView is disabled.
  • The contentView's horizontal constraint is: "H:|[_textView(320)]", i.e. the UITextView is pinned to the left of the cell with an explicit width of 320.
  • The contentView's vertical constraint is: "V:|-0-[_textView]", i.e. the UITextView pinned to the top of the cell.
  • The UITextView has a height constraint set to a constant which the UITextView reports will fit the text.

Here's what it looks like with the cell background set to red, and the UITextView background set to Blue: cell background red, UITextView background blue

I put the project that I've been playing with on GitHub here.

16
Are you implementing the sizeForItemAtIndexPath method?Daniel Galasko
@DanielGalasko I'm not. My understanding is that the new self sizing cells feature in iOS 8 doesn't require you to do that any more.rawbee
not sure where you got that info from but you still need to, take a look at the WWDC excerpt asciiwwdc.com/2014/sessions/226Daniel Galasko
but again it depends on your use case, make sure you implement estimatedItemSizeDaniel Galasko
2019 - It's essential to go look at this: stackoverflow.com/a/58108897/294884Fattie

16 Answers

333
votes

Updated for Swift 5

preferredLayoutAttributesFittingAttributes renamed to preferredLayoutAttributesFitting and use auto sizing


Updated for Swift 4

systemLayoutSizeFittingSize renamed to systemLayoutSizeFitting


Updated for iOS 9

After seeing my GitHub solution break under iOS 9 I finally got the time to investigate the issue fully. I have now updated the repo to include several examples of different configurations for self sizing cells. My conclusion is that self sizing cells are great in theory but messy in practice. A word of caution when proceeding with self sizing cells.

TL;DR

Check out my GitHub project


Self sizing cells are only supported with flow layout so make sure thats what you are using.

There are two things you need to setup for self sizing cells to work.

1. Set estimatedItemSize on UICollectionViewFlowLayout

Flow layout will become dynamic in nature once you set the estimatedItemSize property.

self.flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize

2. Add support for sizing on your cell subclass

This comes in 2 flavours; Auto-Layout or custom override of preferredLayoutAttributesFittingAttributes.

Create and configure cells with Auto Layout

I won't go to in to detail about this as there's a brilliant SO post about configuring constraints for a cell. Just be wary that Xcode 6 broke a bunch of stuff with iOS 7 so, if you support iOS 7, you will need to do stuff like ensure the autoresizingMask is set on the cell's contentView and that the contentView's bounds is set as the cell's bounds when the cell is loaded (i.e. awakeFromNib).

Things you do need to be aware of is that your cell needs to be more seriously constrained than a Table View Cell. For instance, if you want your width to be dynamic then your cell needs a height constraint. Likewise, if you want the height to be dynamic then you will need a width constraint to your cell.

Implement preferredLayoutAttributesFittingAttributes in your custom cell

When this function is called your view has already been configured with content (i.e. cellForItem has been called). Assuming your constraints have been appropriately set you could have an implementation like this:

//forces the system to do one layout pass
var isHeightCalculated: Bool = false

override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    //Exhibit A - We need to cache our calculation to prevent a crash.
    if !isHeightCalculated {
        setNeedsLayout()
        layoutIfNeeded()
        let size = contentView.systemLayoutSizeFitting(layoutAttributes.size)
        var newFrame = layoutAttributes.frame
        newFrame.size.width = CGFloat(ceilf(Float(size.width)))
        layoutAttributes.frame = newFrame
        isHeightCalculated = true
    }
    return layoutAttributes
}

NOTE On iOS 9 the behaviour changed a bit that could cause crashes on your implementation if you are not careful (See more here). When you implement preferredLayoutAttributesFittingAttributes you need to ensure that you only change the frame of your layout attributes once. If you don't do this the layout will call your implementation indefinitely and eventually crash. One solution is to cache the calculated size in your cell and invalidate this anytime you reuse the cell or change its content as I have done with the isHeightCalculated property.

Experience your layout

At this point you should have 'functioning' dynamic cells in your collectionView. I haven't yet found the out-of-the box solution sufficient during my tests so feel free to comment if you have. It still feels like UITableView wins the battle for dynamic sizing IMHO.

Caveats

Be very mindful that if you are using prototype cells to calculate the estimatedItemSize - this will break if your XIB uses size classes. The reason for this is that when you load your cell from a XIB its size class will be configured with Undefined. This will only be broken on iOS 8 and up since on iOS 7 the size class will be loaded based on the device (iPad = Regular-Any, iPhone = Compact-Any). You can either set the estimatedItemSize without loading the XIB, or you can load the cell from the XIB, add it to the collectionView (this will set the traitCollection), perform the layout, and then remove it from the superview. Alternatively you could also make your cell override the traitCollection getter and return the appropriate traits. It's up to you.

Let me know if I missed anything, hope I helped and good luck coding


58
votes

In iOS10 there is new constant called UICollectionViewFlowLayout.automaticSize (formerly UICollectionViewFlowLayoutAutomaticSize), so instead:

self.flowLayout.estimatedItemSize = CGSize(width: 100, height: 100)

you can use this:

self.flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize

It has better performance especially when cells in your collection view have constant width.

Accessing Flow Layout:

override func viewDidLoad() {
   super.viewDidLoad()

   if let flowLayout = collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
      flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
   }
}

Swift 5 Updated:

override func viewDidLoad() {
   super.viewDidLoad()

   if let flowLayout = collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
      flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
    }
}
45
votes

A few key changes to Daniel Galasko's answer fixed all my problems. Unfortunately, I don't have enough reputation to comment directly (yet).

In step 1, when using Auto Layout, simply add a single parent UIView to the cell. EVERYTHING inside the cell must be a subview of the parent. That answered all of my problems. While Xcode adds this for UITableViewCells automatically, it doesn't (but it should) for UICollectionViewCells. According to the docs:

To configure the appearance of your cell, add the views needed to present the data item’s content as subviews to the view in the contentView property. Do not directly add subviews to the cell itself.

Then skip step 3 entirely. It isn't needed.

34
votes

In iOS 10+ this is a very simple 2 step process.

  1. Ensure that all your cell contents are placed within a single UIView (or inside a descendant of UIView like UIStackView which simplifies autolayout a lot). Just like with dynamically resizing UITableViewCells, the whole view hierarchy needs to have constraints configured, from the outermost container to the innermost view. That includes constraints between the UICollectionViewCell and the immediate childview

  2. Instruct the flowlayout of your UICollectionView to size automatically

    yourFlowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
    
14
votes
  1. Add flowLayout on viewDidLoad()

    override func viewDidLoad() {
        super.viewDidLoad() 
        if let flowLayout = infoCollection.collectionViewLayout as? UICollectionViewFlowLayout {
            flowLayout.estimatedItemSize = CGSize(width: 1, height:1)
        }
    }
    
  2. Also, set an UIView as mainContainer for your cell and add all required views inside it.

  3. Refer to this awesome, mind-blowing tutorial for further reference: UICollectionView with autosizing cell using autolayout in iOS 9 & 10

13
votes

EDIT 11/19/19: For iOS 13, just use UICollectionViewCompositionalLayout with estimated heights. Don't waste your time dealing with this broken API.

After struggling with this for some time, I noticed that resizing does not work for UITextViews if you don't disable scrolling:

let textView = UITextView()
textView.scrollEnabled = false
6
votes

contentView anchor mystery:

In one bizarre case this

    contentView.translatesAutoresizingMaskIntoConstraints = false

would not work. Added four explicit anchors to the contentView and it worked.

class AnnoyingCell: UICollectionViewCell {
    
    @IBOutlet var word: UILabel!
    
    override init(frame: CGRect) {
        super.init(frame: frame); common() }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder); common() }
    
    private func common() {
        contentView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            contentView.leftAnchor.constraint(equalTo: leftAnchor),
            contentView.rightAnchor.constraint(equalTo: rightAnchor),
            contentView.topAnchor.constraint(equalTo: topAnchor),
            contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
}

and as usual

    estimatedItemSize = UICollectionViewFlowLayout.automaticSize

in YourLayout: UICollectionViewFlowLayout

Who knows? Might help someone.

Credit

https://www.vadimbulavin.com/collection-view-cells-self-sizing/

stumbled on to the tip there - never saw it anywhere else in all the 1000s articles on this.

3
votes

I did a dynamic cell height of collection view. Here is git hub repo.

And, dig out why preferredLayoutAttributesFittingAttributes is called more than once. Actually, it will be called at least 3 times.

The console log picture : enter image description here

1st preferredLayoutAttributesFittingAttributes:

(lldb) po layoutAttributes
<UICollectionViewLayoutAttributes: 0x7fa405c290e0> index path: (<NSIndexPath:    0xc000000000000016> 
{length = 2, path = 0 - 0}); frame = (15 12; 384 57.5); 

(lldb) po self.collectionView
<UICollectionView: 0x7fa40606c800; frame = (0 57.6667; 384 0);

The layoutAttributes.frame.size.height is current status 57.5.

2nd preferredLayoutAttributesFittingAttributes:

(lldb) po layoutAttributes
<UICollectionViewLayoutAttributes: 0x7fa405c16370> index path: (<NSIndexPath: 0xc000000000000016> 
{length = 2, path = 0 - 0}); frame = (15 12; 384 534.5); 

(lldb) po self.collectionView
<UICollectionView: 0x7fa40606c800; frame = (0 57.6667; 384 0);

The cell frame height changed to 534.5 as our expected. But, the collection view still zero height.

3rd preferredLayoutAttributesFittingAttributes:

(lldb) po layoutAttributes
<UICollectionViewLayoutAttributes: 0x7fa403d516a0> index path: (<NSIndexPath: 0xc000000000000016> 
{length = 2, path = 0 - 0}); frame = (15 12; 384 534.5); 

(lldb) po self.collectionView
<UICollectionView: 0x7fa40606c800; frame = (0 57.6667; 384 477);

You can see the collection view height was changed from 0 to 477.

The behavior is similar to handle scroll:

1. Before self-sizing cell

2. Validated self-sizing cell again after other cells recalculated.

3. Did changed self-sizing cell

At beginning, I thought this method only call once. So I coded as the following:

CGRect frame = layoutAttributes.frame;
frame.size.height = frame.size.height + self.collectionView.contentSize.height;
UICollectionViewLayoutAttributes* newAttributes = [layoutAttributes copy];
newAttributes.frame = frame;
return newAttributes;

This line:

frame.size.height = frame.size.height + self.collectionView.contentSize.height;

will cause system call infinite loop and App crash.

Any size changed, it will validate all cells' preferredLayoutAttributesFittingAttributes again and again until every cells' positions (i.e frames) are no more change.

3
votes

The solution comprises 3 simple steps:

  1. Enabling dynamic cell sizing

flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize

  1. Set the containerView.widthAnchor.constraint from collectionView(:cellForItemAt:)to limit the width of contentView to width of collectionView.
class ViewController: UIViewController, UICollectionViewDataSource {
    ...

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as! MultiLineCell
        cell.textView.text = dummyTextMessages[indexPath.row]
        cell.maxWidth = collectionView.frame.width
        return cell
    }

    ...
}

class MultiLineCell: UICollectionViewCell{
    ....

    var maxWidth: CGFloat? {
        didSet {
            guard let maxWidth = maxWidth else {
                return
            }
            containerViewWidthAnchor.constant = maxWidth
            containerViewWidthAnchor.isActive = true
        }
    }

    ....
}

Since you want to enable self-sizing of UITextView, it has an additional step to;

3. Calculate and set the heightAnchor.constant of UITextView.

So, whenever the width of contentView is set we'll adjust height of UITextView along in didSet of maxWidth.

Inside UICollectionViewCell:

var maxWidth: CGFloat? {
    didSet {
        guard let maxWidth = maxWidth else {
            return
        }
        containerViewWidthAnchor.constant = maxWidth
        containerViewWidthAnchor.isActive = true
        
        let sizeToFitIn = CGSize(width: maxWidth, height: CGFloat(MAXFLOAT))
        let newSize = self.textView.sizeThatFits(sizeToFitIn)
        self.textViewHeightContraint.constant = newSize.height
    }
}

These steps will get you the desired result.

Complete runnable gist

Reference: Vadim Bulavin blog post - Collection View Cells Self-Sizing: Step by Step Tutorial

Screenshot:

enter image description here

2
votes

In addition to above answers,

Just make sure you set estimatedItemSize property of UICollectionViewFlowLayout to some size and do not implement sizeForItem:atIndexPath delegate method.

That's it.

1
votes

If you implement UICollectionViewDelegateFlowLayout method:

- (CGSize)collectionView:(UICollectionView*)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath*)indexPath

When you call collectionview performBatchUpdates:completion:, the size height will use sizeForItemAtIndexPath instead of preferredLayoutAttributesFittingAttributes.

The rendering process of performBatchUpdates:completion will go through the method preferredLayoutAttributesFittingAttributes but it ignores your changes.

1
votes

To whomever it may help,

I had that nasty crash if estimatedItemSize was set. Even if I returned 0 in numberOfItemsInSection. Therefore, the cells themselves and their auto-layout were not the cause of the crash... The collectionView just crashed, even when empty, just because estimatedItemSize was set for self-sizing.

In my case I reorganized my project, from a controller containing a collectionView to a collectionViewController, and it worked.

Go figure.

1
votes

For anyone who tried everything without luck, this is the only thing that got it working for me. For the multiline labels inside cell, try adding this magic line:

label.preferredMaxLayoutWidth = 200

More info: here

Cheers!

0
votes

The example method above does not compile. Here is a corrected version (but untested as to whether or not it works.)

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes 
{
    let attr: UICollectionViewLayoutAttributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes

    var newFrame = attr.frame
    self.frame = newFrame

    self.setNeedsLayout()
    self.layoutIfNeeded()

    let desiredHeight: CGFloat = self.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
    newFrame.size.height = desiredHeight
    attr.frame = newFrame
    return attr
}
0
votes

Update more information:

  • If you use flowLayout.estimatedItemSize, suggest use iOS8.3 later version. Before iOS8.3, it will crash [super layoutAttributesForElementsInRect:rect];. The error message is

    *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[__NSArrayM insertObject:atIndex:]: object cannot be nil'

  • Second, in iOS8.x version, flowLayout.estimatedItemSize will cause different section inset setting did not work. i.e. function: (UIEdgeInsets)collectionView:layout:insetForSectionAtIndex:.

-2
votes

I tried using estimatedItemSize but there were a bunch of bugs when inserting and deleting cells if the estimatedItemSize was not exactly equal to the cell's height. i stopped setting estimatedItemSize and implemented dynamic cell's by using a prototype cell. here's how that's done:

create this protocol:

protocol SizeableCollectionViewCell {
    func fittedSize(forConstrainedSize size: CGSize)->CGSize
}

implement this protocol in your custom UICollectionViewCell:

class YourCustomCollectionViewCell: UICollectionViewCell, SizeableCollectionViewCell {

    @IBOutlet private var mTitle: UILabel!
    @IBOutlet private var mDescription: UILabel!
    @IBOutlet private var mContentView: UIView!
    @IBOutlet private var mTitleTopConstraint: NSLayoutConstraint!
    @IBOutlet private var mDesciptionBottomConstraint: NSLayoutConstraint!

    func fittedSize(forConstrainedSize size: CGSize)->CGSize {

        let fittedSize: CGSize!

        //if height is greatest value, then it's dynamic, so it must be calculated
        if size.height == CGFLoat.greatestFiniteMagnitude {

            var height: CGFloat = 0

            /*now here's where you want to add all the heights up of your views.
              apple provides a method called sizeThatFits(size:), but it's not 
              implemented by default; except for some concrete subclasses such 
              as UILabel, UIButton, etc. search to see if the classes you use implement 
              it. here's how it would be used:
            */
            height += mTitle.sizeThatFits(size).height
            height += mDescription.sizeThatFits(size).height
            height += mCustomView.sizeThatFits(size).height    //you'll have to implement this in your custom view

            //anything that takes up height in the cell has to be included, including top/bottom margin constraints
            height += mTitleTopConstraint.constant
            height += mDescriptionBottomConstraint.constant

            fittedSize = CGSize(width: size.width, height: height)
        }
        //else width is greatest value, if not, you did something wrong
        else {
            //do the same thing that's done for height but with width, remember to include leading/trailing margins in calculations
        }

        return fittedSize
    }
}

now make your controller conform to UICollectionViewDelegateFlowLayout, and in it, have this field:

class YourViewController: UIViewController, UICollectionViewDelegateFlowLayout {
    private var mCustomCellPrototype = UINib(nibName: <name of the nib file for your custom collectionviewcell>, bundle: nil).instantiate(withOwner: nil, options: nil).first as! SizeableCollectionViewCell
}

it will be used as a prototype cell to bind data to and then determine how that data affected the dimension that you want to be dynamic

finally, the UICollectionViewDelegateFlowLayout's collectionView(:layout:sizeForItemAt:) has to be implemented:

class YourViewController: UIViewController, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {

    private var mDataSource: [CustomModel]

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath)->CGSize {

        //bind the prototype cell with the data that corresponds to this index path
        mCustomCellPrototype.bind(model: mDataSource[indexPath.row])    //this is the same method you would use to reconfigure the cells that you dequeue in collectionView(:cellForItemAt:). i'm calling it bind

        //define the dimension you want constrained
        let width = UIScreen.main.bounds.size.width - 20    //the width you want your cells to be
        let height = CGFloat.greatestFiniteMagnitude    //height has the greatest finite magnitude, so in this code, that means it will be dynamic
        let constrainedSize = CGSize(width: width, height: height)

        //determine the size the cell will be given this data and return it
        return mCustomCellPrototype.fittedSize(forConstrainedSize: constrainedSize)
    }
}

and that's it. Returning the cell's size in collectionView(:layout:sizeForItemAt:) in this way preventing me from having to use estimatedItemSize, and inserting and deleting cells works perfectly.