Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamic-height UICollectionView inside a dynamic-height UITableViewCell

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?

like image 583
slider Avatar asked May 27 '19 01:05

slider


2 Answers

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.

like image 158
Mojtaba Hosseini Avatar answered Oct 22 '22 06:10

Mojtaba Hosseini


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
}
like image 11
Olympiloutre Avatar answered Oct 22 '22 05:10

Olympiloutre