Collection View cells with full-width and dynamic height - ios

I'm trying to create a feed of posts, like you'd see in the Twitter or Facebook app. From my understanding, I should be using a Collection View, so I've set one up.
But now, I'm confused as to how to make the cells full-width and the height of the cells dynamic, since the text within the cell can vary from just 1 line to many dozens of lines.
How would I go about doing this?

You may need UICollectionViewFlowLayout instance
let frame: CGRect = ...
let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = CGSize.zero
self.collectionView = UICollectionView(frame: someFrame, collectionViewLayout: layout)
About estimatedItemSize:
The default value of this property is CGSizeZero. Setting it to any other value causes the collection view to query each cell for its actual size using the cell’s preferredLayoutAttributesFitting(_:) method. If all of your cells are the same height, use the itemSize property, instead of this property, to specify the cell size instead.
Then you may design your UICollectionViewCell and in awakeFromNib() add an internal constraint to set exact width.
Then in cell for item at index path method you may do next:
let cell = ... as? MyCustomCell
cell?.load(model: someModel, widthConstraint: collectionView.bounds.width)
Important thing is to handle view size changes on rotation and split screen modes:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.collectionView.collectionViewLayout.invalidateLayout()
}) { _ in }
}

First that you would need is a UITextView.
Make sure it's scroll is disabled and you set the text content accordingly. For this open the Interface Builder, select the text view and there will be a section called "Scroll View". Uncheck Scrolling Enabled. This is very important so that UITextView skips the scrolling and stretches the content as much as required.
ViewController:
Secondly, in your ViewController you need to handle SubView Layout changes. This is called when rotation's are made or any size of the ViewController's view changes.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let layout = self.collection.collectionViewLayout as? UICollectionViewFlowLayout {
layout.estimatedItemSize = CGSize(width: self.collection.frame.size.width-layout.sectionInset.left-layout.sectionInset.right-self.collection.contentInset.left - self.collection.contentInset.right, height: 100.0)
}
}
UICollectionViewCell Subclass:
Once you set the esitmatedItemSize, it's time to provider preferredLayoutAttributesFitting. You will have to override the UICollectionViewCell subclass.
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
layoutAttributes.size = self.getCellHeight()
return layoutAttributes
}
func getCellHeight()->CGSize{
self.setNeedsDisplay()
self.layoutIfNeeded()
let size = self.contentView.systemLayoutSizeFitting(UILayoutFittingExpandedSize)
return size
}

Related

Nest UICollectionView into UITableViewCell

I'm trying to build something
I'm trying to build a tag list view using UICollectionView and nest it into my custom UITableViewCell.
What do I have now
After searching the internet, I find the key to the problem:
Subclass UICollectionView and implement it's intrinsic content size property.
However, when I nest my custom UICollectionView into a self-sizing UITableViewCell, the whole thing doesn't work well. The layout is broken.
No matter how do I change the code, I get one of the following 3 buggy UIs.
The height of the collection view is always wrong, either too small or too large, it can not hug it's content just right.
When I use Debug View Hierarchy to check the views, I find that although the UI is broken, the contentSize property of the collection view has a correct value. It seems that the content size property can not be reflected to the UI in time.
class IntrinsicCollectionView: UICollectionView {
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
isScrollEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
There are many solution about how to create a custom UICollectionView with intrinsic content size. Some of them can work correctly. But when nesting them into a UITableViewCell, none of them works well.
There are also some answer about just nest one UICollectionView into UITableViewCell without other views. But if there are also some UILabel in UITableViewCell, it won't work.
I upload all the code to github. https://github.com/yunhao/nest-collectionview-in-tableviewcell
Thank you!
I'll try to explain what's going on....
To make it easy to understand, in your ListViewController let's work with just one row to begin with:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1 // items.count
}
In your ListViewCell class, add these lines at the end of prepareViews():
// so we can see the element frames
titleLabel.backgroundColor = .green
subtitleLabel.backgroundColor = .cyan
collectionView.backgroundColor = .yellow
In your IntrinsicCollectionView class, let's add a print() statement to give us some information:
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
// add this line
print("collView Width:", frame.width, "intrinsic height:", collectionViewLayout.collectionViewContentSize.height)
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
When I then run the app on an iPhone 8, I get this result:
and I see this in the debug console:
collView Width: 66.0 intrinsic height: 350.0
collView Width: 343.0 intrinsic height: 30.0
What that tells me is that the collection view is asked for its intrinsicContentSize before it has a complete frame.
At that point, it fills in its cells, and its layout ends up with a .collectionViewContentSize.height of 350 (this row has six "tags" cells).
Auto-layout then performs another pass... the collection view now has a valid frame width (based on the cell width)... and the cells are re-laid-out.
Unfortunately, the table view has already set the row height(s), based on the initial collection view intrinsicContentSize.height.
So, two steps that may (should) fix this:
In ListViewCell, invalidate the content size of the collection view when you get the tags:
func setTags(_ tags: [String]) {
self.tags = tags
collectionView.reloadData()
// add this line
collectionView.invalidateIntrinsicContentSize()
}
Then, in ListViewController, we need to reload the table after its frame has changed:
// add this var
var currentWidth: CGFloat = 0
// implement viewDidLayoutSubviews()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if view.frame.width != currentWidth {
currentWidth = view.frame.width
tableView.reloadData()
}
}
That seems (with very quick testing) to give me reliable results:
and on device rotation:

AutoSizing cells: cell width equal to the CollectionView

I'm using AutoSizing cells with Autolayout and UICollectionView.
I can specify constraints in code on cell initialization:
func configureCell() {
snp.makeConstraints { (make) in
make.width.equalToSuperview()
}
}
However, the app crashes as the cell hasn't been yet added to the collectionView.
Questions
At which stage of the cell's lifecycle it is possible to add a
constraint with cell's width?
Is there any default way of making a cell'swidthequal to the
widthof thecollectionViewwithout accessing an instance of
UIScreenorUIWindow`?
Edit
The question is not duplicate, as it is not about how to use the AutoSizing cells feature, but at which stage of the cell lifecycle to apply constraints to achieve the desired result when working with AutoLayout.
To implement self-sizing collection view cells you need to do two things:
Specify estimatedItemSize on UICollectionViewFlowLayout
Implement preferredLayoutAttributesFitting(_:) on your cell
1. Specifying estimatedItemSize on UICollectionViewFlowLayout
The default value of this property is CGSizeZero. Setting it to any other value causes the collection view to query each cell for its actual size using the cell’s preferredLayoutAttributesFitting(_:) method. If all of your cells are the same height, use the itemSize property, instead of this property, to specify the cell size instead.
This is just an estimate which is used to calculate the content size of the scroll view, set it to something sensible.
let collectionViewFlowLayout = UICollectionViewFlowLayout()
collectionViewFlowLayout.estimatedItemSize = CGSize(width: collectionView.frame.width, height: 100)
2. Implement preferredLayoutAttributesFitting(_:) on your UICollectionViewCell subclass
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let autoLayoutAttributes = super.preferredLayoutAttributesFitting(layoutAttributes)
// Specify you want _full width_
let targetSize = CGSize(width: layoutAttributes.frame.width, height: 0)
// Calculate the size (height) using Auto Layout
let autoLayoutSize = contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriority.required, verticalFittingPriority: UILayoutPriority.defaultLow)
let autoLayoutFrame = CGRect(origin: autoLayoutAttributes.frame.origin, size: autoLayoutSize)
// Assign the new size to the layout attributes
autoLayoutAttributes.frame = autoLayoutFrame
return autoLayoutAttributes
}
You'll need to implement sizeForItemAt: to calculate the size.
We've also used a "sizing cell" if your cells have variable height. Eg:
class MyFancyCell: UICollectionViewCell {
class func cellSize(_ content: SomeContent, withWidth width: CGFloat) -> CGSize {
sizingCell.content = content
sizingCell.updateCellLayout(width)
return sizingCell.systemLayoutSizeFitting(UILayoutFittingExpandedSize)
}
fileprivate static let sizingCell = Bundle.main.loadNibNamed("ContentCell", owner: nil, options: nil)!.first as! ContentCell
func updateCellLayout(width: CGFloat) {
//Set constraints and calculate size
}
}

Resize UICollectionViewCell

In my app I have UICollectionView with FlowLayout (vertical). and in collection cell I have label for header and textView for content. Content length may be vary, short or long. I need to implement cell's autoresizing. in my CustomCell class I overrided this method:
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
let desiredHeight = systemLayoutSizeFitting(UILayoutFittingCompressedSize).height
attributes.frame.size.height = desiredHeight
return attributes
}
in ViewController class in viewDidLoad() I wrote this:
if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
layout.estimatedItemSize = CGSize(width: view.frame.width, height: 120)
}
app runs w/o any errors, but cell resizing only after scrolling, not when view appears on screen. is there any issue to fix that?
before scrolling
after scrolling
I believe that you have an update constraint issue, this is why when you scroll the content, you have the constraints already updated and get the right height. Before calling systemLayoutSizeFitting(UILayoutFittingCompressedSize).height you should call layoutIfNeeded()

UICollectionView Self Sizing Cells with Auto Layout

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:
I put the project that I've been playing with on GitHub here.
This answer is outdated from iOS 14 with the addition of compositional layouts. Please consider updating the new API
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.
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
}
}
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.
In iOS 10+ this is a very simple 2 step process.
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
Instruct the flowlayout of your UICollectionView to size automatically
yourFlowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
Add flowLayout on viewDidLoad()
override func viewDidLoad() {
super.viewDidLoad()
if let flowLayout = infoCollection.collectionViewLayout as? UICollectionViewFlowLayout {
flowLayout.estimatedItemSize = CGSize(width: 1, height:1)
}
}
Also, set an UIView as mainContainer for your cell and add all required views inside it.
Refer to this awesome, mind-blowing tutorial for further reference:
UICollectionView with autosizing cell using autolayout in iOS 9 & 10
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
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.
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 :
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.
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.
The solution comprises 3 simple steps:
Enabling dynamic cell sizing
flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
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:
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.
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.
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!
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
}
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:.
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.
In Swift 5, it works for me.
UICollectionViewFlowLayout:
estimatedItemSize = UICollectionViewFlowLayout.automaticSize
UICollectionViewCell:
(ps: I'm using SnapKit)
class Cell: UICollectionViewCell {
let customizedContentView = UIView()
...
func layoutAction() {
contentView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(customizedContentView)
customizedContentView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
then you just need to expand customizedContentView.

UICollectionView inside a UITableViewCell -- dynamic height?

One of our application screens requires us to place a UICollectionView inside of a UITableViewCell. This UICollectionView will have a dynamic number of items, resulting in a height which must be calculated dynamically as well. However, I am running into problems trying to calculate the height of the embedded UICollectionView.
Our overarching UIViewController was created in Storyboards and does make use of auto layout. But, I don't know how to dynamically increase the height of the UITableViewCell based on the height of the UICollectionView.
Can anyone give some tips or advice on how to accomplish this?
The right answer is YES, you CAN do this.
I came across this problem some weeks ago. It is actually easier than you may think. Put your cells into NIBs (or storyboards) and pin them to let auto layout do all the work
Given the following structure:
TableView
TableViewCell
CollectionView
CollectionViewCell
CollectionViewCell
CollectionViewCell
[...variable number of cells or different cell sizes]
The solution is to tell auto layout to compute first the collectionViewCell sizes, then the collection view contentSize, and use it as the size of your cell. This is the UIView method that "does the magic":
-(void)systemLayoutSizeFittingSize:(CGSize)targetSize
withHorizontalFittingPriority:(UILayoutPriority)horizontalFittingPriority
verticalFittingPriority:(UILayoutPriority)verticalFittingPriority
You have to set here the size of the TableViewCell, which in your case is the CollectionView's contentSize.
CollectionViewCell
At the CollectionViewCell you have to tell the cell to layout each time you change the model (e.g.: you set a UILabel with a text, then the cell has to be layout again).
- (void)bindWithModel:(id)model {
// Do whatever you may need to bind with your data and
// tell the collection view cell's contentView to resize
[self.contentView setNeedsLayout];
}
// Other stuff here...
TableViewCell
The TableViewCell does the magic. It has an outlet to your collectionView, enables the auto layout for collectionView cells using estimatedItemSize of the UICollectionViewFlowLayout.
Then, the trick is to set your tableView cell's size at the systemLayoutSizeFittingSize... method. (NOTE: iOS8 or later)
NOTE: I tried to use the delegate cell's height method of the tableView -(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath.but it's too late for the auto layout system to compute the CollectionView contentSize and sometimes you may find wrong resized cells.
#implementation TableCell
- (void)awakeFromNib {
[super awakeFromNib];
UICollectionViewFlowLayout *flow = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
// Configure the collectionView
flow.minimumInteritemSpacing = ...;
// This enables the magic of auto layout.
// Setting estimatedItemSize different to CGSizeZero
// on flow Layout enables auto layout for collectionView cells.
// https://developer.apple.com/videos/play/wwdc2014-226/
flow.estimatedItemSize = CGSizeMake(1, 1);
// Disable the scroll on your collection view
// to avoid running into multiple scroll issues.
[self.collectionView setScrollEnabled:NO];
}
- (void)bindWithModel:(id)model {
// Do your stuff here to configure the tableViewCell
// Tell the cell to redraw its contentView
[self.contentView layoutIfNeeded];
}
// THIS IS THE MOST IMPORTANT METHOD
//
// This method tells the auto layout
// You cannot calculate the collectionView content size in any other place,
// because you run into race condition issues.
// NOTE: Works for iOS 8 or later
- (CGSize)systemLayoutSizeFittingSize:(CGSize)targetSize withHorizontalFittingPriority:(UILayoutPriority)horizontalFittingPriority verticalFittingPriority:(UILayoutPriority)verticalFittingPriority {
// With autolayout enabled on collection view's cells we need to force a collection view relayout with the shown size (width)
self.collectionView.frame = CGRectMake(0, 0, targetSize.width, MAXFLOAT);
[self.collectionView layoutIfNeeded];
// If the cell's size has to be exactly the content
// Size of the collection View, just return the
// collectionViewLayout's collectionViewContentSize.
return [self.collectionView.collectionViewLayout collectionViewContentSize];
}
// Other stuff here...
#end
TableViewController
Remember to enable the auto layout system for the tableView cells at your TableViewController:
- (void)viewDidLoad {
[super viewDidLoad];
// Enable automatic row auto layout calculations
self.tableView.rowHeight = UITableViewAutomaticDimension;
// Set the estimatedRowHeight to a non-0 value to enable auto layout.
self.tableView.estimatedRowHeight = 10;
}
CREDIT: #rbarbera helped to sort this out
I think my solution is much simpler than the one proposed by #PabloRomeu.
Step 1. Create outlet from UICollectionView to UITableViewCell subclass, where UICollectionView is placed. Let, it's name will be collectionView
Step 2. Add in IB for UICollectionView height constraint and create outlet to UITableViewCell subclass too. Let, it's name will be collectionViewHeight.
Step 3. In tableView:cellForRowAtIndexPath: add code:
// deque a cell
cell.frame = tableView.bounds;
[cell layoutIfNeeded];
[cell.collectionView reloadData];
cell.collectionViewHeight.constant = cell.collectionView.collectionViewLayout.collectionViewContentSize.height;
Both table views and collection views are UIScrollView subclasses and thus don't like to be embedded inside another scroll view as they try to calculate content sizes, reuse cells, etc.
I recommend you to use only a collection view for all your purposes.
You can divide it in sections and "treat" some sections' layout as a table view and others as a collection view. After all there's nothing you can't achieve with a collection view that you can with a table view.
If you have a basic grid layout for your collection view "parts" you can also use regular table cells to handle them. Still if you don't need iOS 5 support you should better use collection views.
I read through all the answers. This seems to serve all cases.
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
collectionView.layoutIfNeeded()
collectionView.frame = CGRect(x: 0, y: 0, width: targetSize.width , height: 1)
return collectionView.collectionViewLayout.collectionViewContentSize
}
Pablo Romeu's answer above (https://stackoverflow.com/a/33364092/2704206) helped me immensely with my issue. I had to do a few things differently, however, to get this working for my problem. First off, I didn't have to call layoutIfNeeded() as often. I only had to call it on the collectionView in the systemLayoutSizeFitting function.
Secondly, I had auto layout constraints on my collection view in the table view cell to give it some padding. So I had to subtract the leading and trailing margins from the targetSize.width when setting the collectionView.frame's width. I also had to add the top and bottom margins to the return value CGSize height.
To get these constraint constants, I had the option of either creating outlets to the constraints, hard-coding their constants, or looking them up by an identifier. I decided to go with the third option to make my custom table view cell class easily reusable. In the end, this was everything I needed to get it working:
class CollectionTableViewCell: UITableViewCell {
// MARK: -
// MARK: Properties
#IBOutlet weak var collectionView: UICollectionView! {
didSet {
collectionViewLayout?.estimatedItemSize = CGSize(width: 1, height: 1)
selectionStyle = .none
}
}
var collectionViewLayout: UICollectionViewFlowLayout? {
return collectionView.collectionViewLayout as? UICollectionViewFlowLayout
}
// MARK: -
// MARK: UIView functions
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
collectionView.layoutIfNeeded()
let topConstraintConstant = contentView.constraint(byIdentifier: "topAnchor")?.constant ?? 0
let bottomConstraintConstant = contentView.constraint(byIdentifier: "bottomAnchor")?.constant ?? 0
let trailingConstraintConstant = contentView.constraint(byIdentifier: "trailingAnchor")?.constant ?? 0
let leadingConstraintConstant = contentView.constraint(byIdentifier: "leadingAnchor")?.constant ?? 0
collectionView.frame = CGRect(x: 0, y: 0, width: targetSize.width - trailingConstraintConstant - leadingConstraintConstant, height: 1)
let size = collectionView.collectionViewLayout.collectionViewContentSize
let newSize = CGSize(width: size.width, height: size.height + topConstraintConstant + bottomConstraintConstant)
return newSize
}
}
As a helper function to retrieve a constraint by identifier, I add the following extension:
extension UIView {
func constraint(byIdentifier identifier: String) -> NSLayoutConstraint? {
return constraints.first(where: { $0.identifier == identifier })
}
}
NOTE: You will need to set the identifier on these constraints in your storyboard, or wherever they are being created. Unless they have a 0 constant, then it doesn't matter. Also, as in Pablo's response, you will need to use UICollectionViewFlowLayout as the layout for your collection view. Finally, make sure you link the collectionView IBOutlet to your storyboard.
With the custom table view cell above, I can now subclass it in any other table view cell that needs a collection view and have it implement the UICollectionViewDelegateFlowLayout and UICollectionViewDataSource protocols. Hope this is helpful to someone else!
An alternative to Pablo Romeu's solution is to customise UICollectionView itself, rather than doing the work in table view cell.
The underlying problem is that by default a collection view has no intrinsic size and so cannot inform auto layout of the dimensions to use. You can remedy that by creating a custom subclass which does return a useful intrinsic size.
Create a subclass of UICollectionView and override the following methods
override func intrinsicContentSize() -> CGSize {
self.layoutIfNeeded()
var size = super.contentSize
if size.width == 0 || size.height == 0 {
// return a default size
size = CGSize(width: 600, height:44)
}
return size
}
override func reloadData() {
super.reloadData()
self.layoutIfNeeded()
self.invalidateIntrinsicContentSize()
}
(You should also override the related methods: reloadSections, reloadItemsAtIndexPaths in a similar way to reloadData())
Calling layoutIfNeeded forces the collection view to recalculate the content size which can then be used as the new intrinsic size.
Also, you need to explicitly handle changes to the view size (e.g. on device rotation) in the table view controller
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
dispatch_async(dispatch_get_main_queue()) {
self.tableView.reloadData()
}
}
Easiest approach I've came up with, so far, Credits to #igor answer above,
In your tableviewcell class just insert this
override func layoutSubviews() {
self.collectionViewOutlet.constant = self.postPoll.collectionViewLayout.collectionViewContentSize.height
}
and of course, change the collectionviewoutlet with your outlet in the cell's class
I was facing the same issue recently and I almost tried every solution in the answers, some of them worked and others didn't my main concern about #PabloRomeu approach is that if you have other contents in the cell (other than the collection view) you will have to calculate their heights and the heights of their constraints and return the result to get the auto layout right and I don't like to calculate things manually in my code. So here is the solution that worked fine for me without doing any manual calculations in my code.
in the cellForRow:atIndexPath of the table view I do the following:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//do dequeue stuff
//initialize the the collection view data source with the data
cell.frame = CGRect.zero
cell.layoutIfNeeded()
return cell
}
I think what happens here is that I force the tableview cell to adjust its height after the collection view height has been calculated. (after providing the collectionView date to the data source)
I would put a static method on the collection view class that will return a size based on the content it will have. Then use that method in the heightForRowAtIndexPath to return the proper size.
Also note that you can get some weird behavior when you embed these kinds of viewControllers. I did it once and had some weird memory issues I never worked out.
Maybe my variant will be useful; i've been deciding this task during last two hours. I don't pretend it's 100% correct or optimal, but my skill's very small yet and i'd like to hear comments from experts. Thank you.
One important note: this works for static table - it's specified by my current work.
So, all I use is viewWillLayoutSubviews of tableView. And a little bit more.
private var iconsCellHeight: CGFloat = 500
func updateTable(table: UITableView, withDuration duration: NSTimeInterval) {
UIView.animateWithDuration(duration, animations: { () -> Void in
table.beginUpdates()
table.endUpdates()
})
}
override func viewWillLayoutSubviews() {
if let iconsCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 1)) as? CategoryCardIconsCell {
let collectionViewContentHeight = iconsCell.iconsCollectionView.contentSize.height
if collectionViewContentHeight + 17 != iconsCellHeight {
iconsCellHeight = collectionViewContentHeight + 17
updateTable(tableView, withDuration: 0.2)
}
}
}
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch (indexPath.section, indexPath.row) {
case ...
case (1,0):
return iconsCellHeight
default:
return tableView.rowHeight
}
}
I know, that the collectionView is located in the first row of the second section;
Let the height of the row is 17 p. bigger, than its content height;
iconsCellHeight is a random number as the program starts (i know, that in the portrait form it has to be exactly 392, but it's not important). If the content of collectionView + 17 is not equal this number, so change its value. Next time in this situation the condition gives FALSE;
After all update the tableView. In my case its the combination of two operations (for nice updating of extending rows);
And of course, in the heightForRowAtIndexPath add one row to code.
I get idea from #Igor post and invest my time to this for my project with swift
Just past this in your
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//do dequeue stuff
cell.frame = tableView.bounds
cell.layoutIfNeeded()
cell.collectionView.reloadData()
cell.collectionView.heightAnchor.constraint(equalToConstant: cell.collectionView.collectionViewLayout.collectionViewContentSize.height)
cell.layoutIfNeeded()
return cell
}
Addition:
If you see your UICollectionView choppy when loading cells.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
//do dequeue stuff
cell.layer.shouldRasterize = true
cell.layer.rasterizationScale = UIScreen.main.scale
return cell
}
Pablo's solution did not work very well for me, I had strange visual effects (the collectionView not adjusting correctly).
What worked was to adjust the height constraint of the collectionView (as a NSLayoutConstraint) to the collectionView contentSize during layoutSubviews(). This is the method called when autolayout is applied to the cell.
// Constraint on the collectionView height in the storyboard. Priority set to 999.
#IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
// Method called by autolayout to layout the subviews (including the collectionView).
// This is triggered with 'layoutIfNeeded()', or by the viewController
// (happens between 'viewWillLayoutSubviews()' and 'viewDidLayoutSubviews()'.
override func layoutSubviews() {
collectionViewHeightConstraint.constant = collectionView.contentSize.height
super.layoutSubviews()
}
// Call `layoutIfNeeded()` when you update your UI from the model to trigger 'layoutSubviews()'
private func updateUI() {
layoutIfNeeded()
}
func configure(data: [Strings]) {
names = data
contentView.layoutIfNeeded()
collectionviewNames.reloadData()
}
Short and sweet. Consider the above method in your tableViewCell class. You would probably call it from func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell after dequeing your cell. Before calling reloadData on your collection view, in your tableCell, you need to tell the collection view to lay out its subviews, if layout updates are pending.
In your UITableViewDelegate:
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
return ceil(itemCount/4.0f)*collectionViewCellHeight;
}
Substitute itemCount and CollectionViewCellHeight with the real values. If you have an array of arrays itemCount might be:
self.items[indexPath.row].count
Or whatever.
1.Create dummy cell.
2.Use collectionViewContentSize method on UICollectionViewLayout of UICollectionView using current data.
You can calculate the height of the collection based on its properties like itemSize, sectionInset, minimumLineSpacing, minimumInteritemSpacing, if your collectionViewCell has the border of a rule.

Resources