27
votes

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?

4
Can you attach an image about what you actually desired ?Mojtaba Hosseini
you need to calculate all cells height first to find the tallest one! That could have performance issues. And maybe it contains a cell that is taller than the entire tableview. Do you mind?Mojtaba Hosseini
Didn't you get your answer ?Mojtaba Hosseini

4 Answers

18
votes

There are some steps to achieve this:

  • Defining selfsizing UICollectionViewCell
  • Defining selfsizing UICollectionView
  • Defining selfsizing UITableViewCell
  • Calculate all cell sizes and find the tallest (Could be expensive)
  • Reload higher order view of any child's bounds changed

- Defining selfsizing UICollectionViewCell

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*/ }

- Defining selfsizing 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.

- Defining selfsizing 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:

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.

11
votes

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:

  1. First I register a NSLayoutConstraint for the UICollectionView, which already has a topAnchor and a bottomAnchor equal to its superview

    height constraint on UICollectionView inside UITableViewCell

    class ChatButtonsTableViewCell: UITableViewCell {
    
        @IBOutlet weak var containerView: UIView!
        @IBOutlet weak var buttonsCollectionView: UICollectionView!
        @IBOutlet weak var buttonsCollectionViewHeightConstraint: NSLayoutConstraint! // reference
    
    }
    
  2. 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
}
1
votes

It didn't work in your code because cell's systemLayoutSizeFitting was called before collectionView finished laying out. To make it work, you need to update table view layout when collectionView is done laying out.

I wrote detailed instructions on how to do self sizing with collection view inside table view cell here. I also include sample codes here. Below is the gist of it:

  1. Capture event when collection view is done laying out. If you subclass UICollectionView, override layoutSubviews. If you subclass UIViewController or UICollectionViewController, override viewDidLayoutSubviews.
  2. Hook up this event handler to table view cell to update table view layout when collection view finishes laying out.
  3. Override systemLayoutSizeFitting in table view cell to return collection view's contentSize, which you already did.
  4. Don't reload the table rows while you only need to update its layout (automatic height). I see some answers here suggesting reloadData or reloadRows. If your collection view is heavy, don't do it. A simple tableView.beginUpdates() and tableView.endUpdates() will update row heights without reloading the rows.
class CollectionViewController: UICollectionViewController {
    
    //  Set this action during initialization to get a callback when the collection view finishes its layout.
    //  To prevent infinite loop, this action should be called only once. Once it is called, it resets itself
    //  to nil.
    var didLayoutAction: (() -> Void)?
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        didLayoutAction?()
        didLayoutAction = nil   //  Call only once
    }
}
class CollectionViewTableViewCell: UITableViewCell {
    
    var collectionViewController: CollectionViewController!

    override func awakeFromNib() {
        super.awakeFromNib()
        
        collectionViewController = CollectionViewController(nibName: String(describing: CollectionViewController.self), bundle: nil)
        
        //  Need to update row height when collection view finishes its layout.
        collectionViewController.didLayoutAction = updateRowHeight
        
        contentView.addSubview(collectionViewController.view)
        collectionViewController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionViewController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            collectionViewController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            collectionViewController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
            collectionViewController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }

    private func updateRowHeight() {
        DispatchQueue.main.async { [weak self] in
            self?.tableView?.updateRowHeightsWithoutReloadingRows()
        }
    }
}
extension UITableView {
    func updateRowHeightsWithoutReloadingRows(animated: Bool = false) {
        let block = {
            self.beginUpdates()
            self.endUpdates()
        }
        
        if animated {
            block()
        }
        else {
            UIView.performWithoutAnimation {
                block()
            }
        }
    }
}

extension UITableViewCell {
    var tableView: UITableView? {
        return (next as? UITableView) ?? (parentViewController as? UITableViewController)?.tableView
    }
}

extension UIView {
    var parentViewController: UIViewController? {
        if let nextResponder = self.next as? UIViewController {
            return nextResponder
        } else if let nextResponder = self.next as? UIView {
            return nextResponder.parentViewController
        } else {
            return nil
        }
    }
}

Also note that if your app target is iOS 14, consider the new UICollectionLayoutListConfiguration. It makes collection view layout just like table view row. Here is the WWDC-20 video that shows how to do it.

0
votes

I just had a similar problem a couple of days ago, getting the correct height of a UICollectionView within a UITableViewCell. None of the solutions worked. I finally found a solution for resizing cells on device rotate

In short, I update the table view with the function:

- (void) reloadTableViewSilently {
   dispatch_async(dispatch_get_main_queue(), ^{
      [self.tableView beginUpdates];
      [self.tableView endUpdates];
   });
}

The function is called in viewWillAppear and in viewWillTransitionToSize: withTransitionCoordinator

Works like a charm even for device rotation. Hope this helps.