In iOS 12, When Does the Uicollectionview Layout Cells, Use Autolayout in Nib

In iOS 12, when does the UICollectionView layout cells, use autolayout in nib

For all solutions, note that there is no need to explicitly call reloadData in viewDidLoad: it will happen automatically.

Solution 1

Inspired by Samantha idea: invalidateLayout asynchronously in viewDidLoad.

override func viewDidLoad() {
super.viewDidLoad()

//[...]

for _ in 0 ..< 1000 {
array.append(randomKeyByBitLength(Int(arc4random_uniform(8)))!)
}

DispatchQueue.main.async {
self.collectionView.collectionViewLayout.invalidateLayout()
}
}

Solution 2

(imperfect, see DHennessy13 improvement on it)

Based on Peter Lapisu answer. invalidateLayout in viewWillLayoutSubviews.

override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
collectionView.collectionViewLayout.invalidateLayout()
}

As noted by DHennessy13, this current solution with viewWillLayoutSubviews is imperfect as it will invalidateLayout when rotating the screen.

You may follow DHennessy13 improvement regarding this solution.

Solution 3

Based on a combination of Tyler Sheaffer answer, Shawn Aukstak port to Swift and Samantha idea. Subclass your CollectionView to perform invalidateLayout on layoutSubviews.

class AutoLayoutCollectionView: UICollectionView {

private var shouldInvalidateLayout = false

override func layoutSubviews() {
super.layoutSubviews()
if shouldInvalidateLayout {
collectionViewLayout.invalidateLayout()
shouldInvalidateLayout = false
}
}

override func reloadData() {
shouldInvalidateLayout = true
super.reloadData()
}
}

This solution is elegant as it doesn't require to change your ViewController code. I've implemented it on branch AutoLayoutCollectionView of this sample project https://github.com/Coeur/StackOverflow51375566/tree/AutoLayoutCollectionView.

Solution 4

Rewrite UICollectionViewCell default constraints. See Larry answer.

Solution 5

Implement collectionView(_:layout:sizeForItemAt:) and return cell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize). See matt answer.

In iOS 12, when UICollectionView is created using Self Sizing cells with constraints, upon reload layout has wrong height until I scroll

I think the issue is coming from you calling:

flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize

in your project.

If you reset this to something like:

layoutFlow.estimatedItemSize = CGSize(width: UIScreen.main.bounds.size.width, height: 100)

Then it immediately runs smoother.

Note: It is worth noting that the estimated height you set your cells to matters. If you set it to 0 then reloadData() will scroll the collectionView to the top. Setting an accurate value seems to work best in testing

There are still a few bugs which seem to be fixed by removing the

collectionView.collectionViewLayout.invalidateLayout()

and swapping it for something like

collectionView.reloadData()
collectionView.reloadItems(at: collectionView.indexPathsForVisibleItems)

I don't know the specific use that you are wanting this for so I can't fully test how it should be but invalidateLayout seems like the wrong way to reload the collectionView.

Cell is in the center of collection view when there is only one cell

Basically this is the same issue I discuss here:

https://stackoverflow.com/a/52428617/341994

In sum, the feature you are trying to use, self-sizing collection view cells, doesn't work and has never worked. That is why, as you correctly say,

If I use itemSize instead of estimatedItemSize, it will be OK

That's right! It isn't your bug; it's an iOS bug. Self-sizing collection view cells do not work. Either the app crashes or the flow layout is incorrect.

So that's the solution. Do not use estimatedItemSize. Instead, calculate the correct size yourself and provide it in your implementation of

func collectionView(_ collectionView: UICollectionView, 
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
// calculate/obtain correct size and return it
}

iOS - How to make an UICollectionViewCell adapt its height according to its content ? (containing an UITableView)

Maybe it's not the answer you wanted, but here're my two cents. For your particular requirements, the better solution is moving away from UITableView, and use UIStackView or your custom container view.

Here's why:

  1. UITableView is a subclass of UIScrollView, but since you've disabled its scrolling feature, you don't need a UIScrollView.
  2. UITableView is mainly used to reuse cells, to improve performance and make code more structured. But since you're making it as large as its content size, none of your cells are reused, so features of UITableView is not taken any advantage of.

Thus, actually you don't need and you should not use either UITableView or UIScrollView inside the UICollectionViewCell for your requirements.

If you agree with above part, here're some learnings from our practices:

  1. We always move most of the underlying views and code logics, mainly data assembling, into a UIView based custom view, instead of putting in UITableViewCell or UICollectionViewCell directly. Then add it to UITableViewCell or UICollectionViewCell's contentView and setup constraints. With this structure, we can reuse our custom view in more scenarios.
  2. For requirements similar to yours, we'll create a factory class to create "rows" similar to how you create "cells" for your UITableView, add them into a vertical UIStackView, create constraints deciding UIStackView's width. Auto layout will take care of the rest things.
  3. In your usage with UICollectionViewCell, to calculate the wanted height, inside preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) func of your cell, you can use contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) to calculate the height, do some check and return. Also, remember to invalidate layout when the width of the UICollectionView changes.

UICollectionView Self Sizing Cells with Auto Layout


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.

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



UICollectionView, full width cells, allow autolayout dynamic height?


1. Solution for iOS 13+

With Swift 5.1 and iOS 13, you can use Compositional Layout objects in order to solve your problem.

The following complete sample code shows how to display multiline UILabel inside full-width UICollectionViewCell:

CollectionViewController.swift

import UIKit

class CollectionViewController: UICollectionViewController {

let items = [
[
"Lorem ipsum dolor sit amet.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
],
[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
],
[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Lorem ipsum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
]
]

override func viewDidLoad() {
super.viewDidLoad()

let size = NSCollectionLayoutSize(
widthDimension: NSCollectionLayoutDimension.fractionalWidth(1),
heightDimension: NSCollectionLayoutDimension.estimated(44)
)
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: 1)

let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 10

let headerFooterSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(40)
)
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerFooterSize,
elementKind: "SectionHeaderElementKind",
alignment: .top
)
section.boundarySupplementaryItems = [sectionHeader]

let layout = UICollectionViewCompositionalLayout(section: section)
collectionView.collectionViewLayout = layout
collectionView.register(CustomCell.self, forCellWithReuseIdentifier: "CustomCell")
collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView")
}

override func numberOfSections(in collectionView: UICollectionView) -> Int {
return items.count
}

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items[section].count
}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) as! CustomCell
cell.label.text = items[indexPath.section][indexPath.row]
return cell
}

override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView", for: indexPath) as! HeaderView
headerView.label.text = "Header"
return headerView
}

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { context in
self.collectionView.collectionViewLayout.invalidateLayout()
}, completion: nil)
}

}

HeaderView.swift

import UIKit

class HeaderView: UICollectionReusableView {

let label = UILabel()

override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .magenta

addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

}

CustomCell.swift

import UIKit

class CustomCell: UICollectionViewCell {

let label = UILabel()

override init(frame: CGRect) {
super.init(frame: frame)

label.numberOfLines = 0
backgroundColor = .orange
contentView.addSubview(label)

label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

}

Expected display:

Sample Image


2. Solution for iOS 11+

With Swift 5.1 and iOS 11, you can subclass UICollectionViewFlowLayout and set its estimatedItemSize property to UICollectionViewFlowLayout.automaticSize (this tells the system that you want to deal with autoresizing UICollectionViewCells). You'll then have to override layoutAttributesForElements(in:) and layoutAttributesForItem(at:) in order to set cells width. Lastly, you'll have to override your cell's preferredLayoutAttributesFitting(_:) method and compute its height.

The following complete code shows how to display multiline UILabel inside full-width UIcollectionViewCell (constrained by UICollectionView's safe area and UICollectionViewFlowLayout's insets):

CollectionViewController.swift

import UIKit

class CollectionViewController: UICollectionViewController {

let items = [
[
"Lorem ipsum dolor sit amet.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
],
[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
],
[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Lorem ipsum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
]
]
let customFlowLayout = CustomFlowLayout()

override func viewDidLoad() {
super.viewDidLoad()

customFlowLayout.sectionInsetReference = .fromContentInset // .fromContentInset is default
customFlowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
customFlowLayout.minimumInteritemSpacing = 10
customFlowLayout.minimumLineSpacing = 10
customFlowLayout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
customFlowLayout.headerReferenceSize = CGSize(width: 0, height: 40)

collectionView.collectionViewLayout = customFlowLayout
collectionView.contentInsetAdjustmentBehavior = .always
collectionView.register(CustomCell.self, forCellWithReuseIdentifier: "CustomCell")
collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView")
}

override func numberOfSections(in collectionView: UICollectionView) -> Int {
return items.count
}

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items[section].count
}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) as! CustomCell
cell.label.text = items[indexPath.section][indexPath.row]
return cell
}

override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "HeaderView", for: indexPath) as! HeaderView
headerView.label.text = "Header"
return headerView
}

}

CustomFlowLayout.swift

import UIKit

final class CustomFlowLayout: UICollectionViewFlowLayout {

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributesObjects = super.layoutAttributesForElements(in: rect)?.map{ $0.copy() } as? [UICollectionViewLayoutAttributes]
layoutAttributesObjects?.forEach({ layoutAttributes in
if layoutAttributes.representedElementCategory == .cell {
if let newFrame = layoutAttributesForItem(at: layoutAttributes.indexPath)?.frame {
layoutAttributes.frame = newFrame
}
}
})
return layoutAttributesObjects
}

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let collectionView = collectionView else {
fatalError()
}
guard let layoutAttributes = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes else {
return nil
}

layoutAttributes.frame.origin.x = sectionInset.left
layoutAttributes.frame.size.width = collectionView.safeAreaLayoutGuide.layoutFrame.width - sectionInset.left - sectionInset.right
return layoutAttributes
}

}

HeaderView.swift

import UIKit

class HeaderView: UICollectionReusableView {

let label = UILabel()

override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .magenta

addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

}

CustomCell.swift

import UIKit

class CustomCell: UICollectionViewCell {

let label = UILabel()

override init(frame: CGRect) {
super.init(frame: frame)

label.numberOfLines = 0
backgroundColor = .orange
contentView.addSubview(label)

label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let layoutAttributes = super.preferredLayoutAttributesFitting(layoutAttributes)
layoutIfNeeded()
layoutAttributes.frame.size = systemLayoutSizeFitting(UIView.layoutFittingCompressedSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
return layoutAttributes
}

}

Here are some alternative implementations for preferredLayoutAttributesFitting(_:):

override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let targetSize = CGSize(width: layoutAttributes.frame.width, height: 0)
layoutAttributes.frame.size = contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
return layoutAttributes
}
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
label.preferredMaxLayoutWidth = layoutAttributes.frame.width
layoutAttributes.frame.size.height = contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
return layoutAttributes
}

Expected display:

Sample Image

Variable width & height of UICollectionViewCell using AutoLayout with Storyboard (without XIB)

If you are using constraints, you don't need to set a size per cell. So you should remove your delegate method func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize

Instead you need to set a size in estimatedItemSize by setting this to a value other than CGSizeZero you are telling the layout that you don't know the exact size yet. The layout will then ask each cell for it's size and it should be calculated when it's required.

let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout
layout.estimatedItemSize = someReasonableEstimatedSize()


Related Topics



Leave a reply



Submit