I have a horizontal UICollectionView
pinned to all edges of a UITableViewCell
. Items in the collection view are dynamically sized, and I want to make the table view's height equal to that of the tallest collection view cell.
The views are structured as follows:
UITableView
UITableViewCell
UICollectionView
UICollectionViewCell
UICollectionViewCell
UICollectionViewCell
class TableViewCell: UITableViewCell {
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: self.frame, collectionViewLayout: layout)
return collectionView
}()
var layout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = CGSize(width: 350, height: 20)
layout.scrollDirection = .horizontal
return layout
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
let nib = UINib(nibName: String(describing: CollectionViewCell.self), bundle: nil)
collectionView.register(nib, forCellWithReuseIdentifier: CollectionViewCellModel.identifier)
addSubview(collectionView)
// (using SnapKit)
collectionView.snp.makeConstraints { (make) in
make.leading.equalToSuperview()
make.trailing.equalToSuperview()
make.top.equalToSuperview()
make.bottom.equalToSuperview()
}
}
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 layout.collectionViewContentSize
}
override func layoutSubviews() {
super.layoutSubviews()
let spacing: CGFloat = 50
layout.sectionInset = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
layout.minimumLineSpacing = spacing
}
}
class OptionsCollectionViewCell: UICollectionViewCell {
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
return contentView.systemLayoutSizeFitting(CGSize(width: targetSize.width, height: 1))
}
}
class CollectionViewFlowLayout: UICollectionViewFlowLayout {
private var contentHeight: CGFloat = 500
private var contentWidth: CGFloat = 200
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
if let attrs = super.layoutAttributesForElements(in: rect) {
// perform some logic
contentHeight = /* something */
contentWidth = /* something */
return attrs
}
return nil
}
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
}
In my UIViewController
, I have tableView.estimatedRowHeight = 500
and tableView.rowHeight = UITableView.automaticDimension
.
I'm trying to make the table view cell's size in systemLayoutSizeFitting
equal to the collectionViewContentSize
, but it's called before the layout has had a chance to set collectionViewContentSize
to the correct value. Here's the order in which these methods are called:
cellForRowAt
CollectionViewFlowLayout collectionViewContentSize: (200.0, 500.0) << incorrect/original value
**OptionsTableViewCell systemLayoutSizeFitting (200.0, 500.0) << incorrect/original value**
CollectionViewFlowLayout collectionViewContentSize: (200.0, 500.0) << incorrect/original value
willDisplay cell
CollectionViewFlowLayout collectionViewContentSize: (200.0, 500.0) << incorrect/original value
TableViewCell layoutSubviews() collectionViewContentSize.height: 500.0 << incorrect/original value
CollectionViewFlowLayout collectionViewContentSize: (200.0, 500.0) << incorrect/original value
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize: (450.0, 20.0)
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize: (450.0, 20.0)
CollectionViewCell systemLayoutSizeFitting
CollectionViewCell systemLayoutSizeFitting
CollectionViewCell systemLayoutSizeFitting
CollectionViewFlowLayout collectionViewContentSize: (450.0, 20.0)
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize: (450.0, 301.0) << correct size
CollectionViewFlowLayout collectionViewContentSize: (450.0, 301.0) << correct size
TableViewCell layoutSubviews() collectionViewContentSize.height: 301.0 << correct height
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize: (450.0, 301.0) << correct size
TableViewCell layoutSubviews() collectionViewContentSize.height: 301.0 << correct height
How can I make my table view cell's height equal to my collection view's tallest item, represented by the layout's collectionViewContentSize
?
There are some steps to achieve this:
UICollectionViewCell
UICollectionView
UITableViewCell
bounds
changedUICollectionViewCell
You have two options here:
- use autolayout with selfsizing UI elements (like UIImageView
or UILabel
or etc.)
- calculate and provide it's size your self
- Use autolayout with selfsizing UI elements:
if you set the estimatedItemsSize
to be automatic
and layout cell subviews correctly with selfsizing UI elements, it would be automatically sizing on the fly. BUT be aware of expensive layout time and lags because of complex layouts. Specially on older devices.
- Calculate and provide it's size your self:
One of the best places that you can setup the cell size is intrinsicContentSize
. Although there are other places to do this (eg. in flowLayout class or etc), this is were you can guarantee that cell will not conflict with other selfsizing UI elements:
override open var intrinsicContentSize: CGSize { return /*CGSize you prefered*/ }
UICollectionView
Any UIScrollView
subclass can be selfsizing duo to it's contentSize
. Since collectionView and tableView content sizes are constantly changing, you should inform the layouter
(not known as layouter anywhere officially):
protocol CollectionViewSelfSizingDelegate: class {
func collectionView(_ collectionView: UICollectionView, didChageContentSizeFrom oldSize: CGSize, to newSize: CGSize)
}
class SelfSizingCollectionView: UICollectionView {
weak var selfSizingDelegate: CollectionViewSelfSizingDelegate?
override open var intrinsicContentSize: CGSize {
return contentSize
}
override func awakeFromNib() {
super.awakeFromNib()
if let floawLayout = collectionViewLayout as? UICollectionViewFlowLayout {
floawLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
} else {
assertionFailure("Collection view layout is not flow layout. SelfSizing may not work")
}
}
override open var contentSize: CGSize {
didSet {
guard bounds.size.height != contentSize.height else { return }
frame.size.height = contentSize.height
invalidateIntrinsicContentSize()
selfSizingDelegate?.collectionView(self, didChageContentSizeFrom: oldValue, to: contentSize)
}
}
}
So after overriding intrinsicContentSize
we observe for changes of contentSize and inform the selfSizingDelegate
that will need to know about this.
UITableViewCell
This cells can be selfsizing by implementing this in their tableView
:
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
We will put a selfSizingCollectionView
in this cell and implement this:
class SelfSizingTableViewCell: UITableViewCell {
@IBOutlet weak var collectionView: SelfSizingCollectionView!
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
return collectionView.frame.size
}
override func awakeFromNib() {
...
collectionView.selfSizingDelegate = self
}
}
So we conform the delegate and implement the function to update cell.
extension SelfSizingTableViewCell: CollectionViewSelfSizingDelegate {
func collectionView(_ collectionView: UICollectionView, didChageContentSizeFrom oldSize: CGSize, to newSize: CGSize) {
if let indexPath = indexPath {
tableView?.reloadRows(at: [indexPath], with: .none)
} else {
tableView?.reloadData()
}
}
}
Note that I wrote some helper extensions for UIView
and UITableViewCell
to access parent table view:
extension UIView {
var parentViewController: UIViewController? {
var parentResponder: UIResponder? = self
while parentResponder != nil {
parentResponder = parentResponder!.next
if let viewController = parentResponder as? UIViewController {
return viewController
}
}
return nil
}
}
extension UITableViewCell {
var tableView: UITableView? {
return (next as? UITableView) ?? (parentViewController as? UITableViewController)?.tableView
}
var indexPath: IndexPath? {
return tableView?.indexPath(for: self)
}
}
To this point, your collection view will have exactly needed height (if it is vertical) but you should remember to limit all cell widths in order to not goes beyond screen width.
extension SelfSizingTableViewCell: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: frame.width, height: heights[indexPath.row])
}
}
Notice heights
? this is where you should cache (manual/automatic) calculated height of all cells in order to not getting laggy. (I generate all of them randomly for testing purposes)
Last two steps are already covered but you should consider some important things about the case you seek:
1 - Autolayout could be epic performance killer if you ask it to calculate all cellsizes for you, but it's reliable.
2 - You must cache all sizes and get the tallest and return it in tableView(:,heightForRowAt:)
(No more need for selfsizing tableViewCell
)
3 - Consider the tallest one could be taller than entire tableView
and 2D scrolling would be strange.
4 - If you reuse a cell containing a collectionView
or tableView
, the scroll offset, inset and many other attributes behave strange the way you're not expecting.
This is one of the most complex layouts of all you could meet, but once you get used to it, it become simple.
I managed to achieve exactly what you want to do with a height constraint reference for the CollectionView (however I don't use SnapKit, neither programaticUI).
Here is how I did, it may help:
First I register a NSLayoutConstraint
for the UICollectionView, which already has a topAnchor
and a bottomAnchor
equal to its superview
class ChatButtonsTableViewCell: UITableViewCell {
@IBOutlet weak var containerView: UIView!
@IBOutlet weak var buttonsCollectionView: UICollectionView!
@IBOutlet weak var buttonsCollectionViewHeightConstraint: NSLayoutConstraint! // reference
}
Then in my UITableViewDelegate
implementation, on cellForRowAt, I set the height constraint's constant equal to the actuall collectionView frame like so :
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// let item = items[indexPath.section]
let item = groupedItems[indexPath.section][indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: ChatButtonsTableViewCell.identifier, for: indexPath) as! ChatButtonsTableViewCell
cell.chatMessage = nil // reusability
cell.buttonsCollectionView.isUserInteractionEnabled = true
cell.buttonsCollectionView.reloadData() // load actual content of embedded CollectionView
cell.chatMessage = groupedItems[indexPath.section][indexPath.row] as? ChatMessageViewModelChoicesItem
// >>> Compute actual height
cell.layoutIfNeeded()
let height: CGFloat = cell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height
cell.buttonsCollectionViewHeightConstraint.constant = height
// <<<
return cell
}
My assumption is that you could use this methodology to set you collectionView's height to the height of the biggest cell, replacing let height: CGFloat = cell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height
with the actual height you want.
Plus, if you are using an horizontal UICollectionView, my guess is that you don't even have to change this line, considering that the contentSize.height will be equal to your biggest cell height.
It works flawlessly for me, hope it helps
EDIT
Just thinking, you may need to place some layoutIfNeeded()
and invalidateLayout()
here and there. If it doesn't work straight away I can tell you where I put these.
EDIT 2
Also forgot to mention that I implemented willDisplay
and estimatedHeightForRowAt
.
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if let buttonCell = cell as? ChatButtonsTableViewCell {
cellHeights[indexPath] = buttonCell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height
} else {
cellHeights[indexPath] = cell.frame.size.height
}
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return cellHeights[indexPath] ?? 70.0
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With