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

6.6k Views Asked by At

I don't know why it is so complicated to design cells that can adapt to its content. It shouldn't need that much code, I still don't understand why UIKit can't handle this properly.

Anyway, here is my issue (I have edited the whole post):

I have an UICollectionViewCell that contains an UITableView.

Here is my sizeForItem method :

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {

        var cellWidth: CGFloat = collectionView.bounds.size.width 
        var cellHeight: CGFloat = 0

        let cellConfigurator = items[indexPath.item].cellConfigurator

        if type(of: cellConfigurator).reuseId == "MoonCollectionViewCell" {
            if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: type(of: cellConfigurator).reuseId, for: indexPath) as? MoonCollectionViewCell {
               cell.contentView.layoutIfNeeded()
               let size = cell.selfSizedTableView.intrinsicContentSize
               cellHeight = size.height
            }
        }

        return CGSize.init(width: cellWidth, height: cellHeight)
    }

sizeForItem is called before cellForItem, that's the reason of the layoutIfNeeded, because I couldn't get the correct intrinsic content size.

I have removed the XIB as suggested, and designed my UICollectionViewCell within the Storyboard.

Here is my UICollectionViewCell designed within a Storyboard (only the UITableViewCell is designed in a XIB file)

I only added an UITableView within the UICollectionViewCell.
I want the UICollectionViewCell to adapt its size according to the height of the tableView.

Now here is my tableView :

I have created a subclass of UITableView (from this post)

class SelfSizedTableView: UITableView {
    var maxHeight: CGFloat = UIScreen.main.bounds.size.height

    override func reloadData() {
        super.reloadData()
        self.invalidateIntrinsicContentSize()
        self.layoutIfNeeded()
    }

    override var intrinsicContentSize: CGSize {
        let height = min(contentSize.height, maxHeight)
        return CGSize(width: contentSize.width, height: height)
    }
}

Please note that I have disabled scrolling, I have dynamic prototype for the tableView cells, the style is grouped.

EDIT : Check the configure method, it comes from a protocol I used to configure in a generic way all my UICollectionViewCell

func configure(data: [MoonImages]) {

        selfSizedTableView.register(UINib.init(nibName: "MoonTableViewCell", bundle: nil), forCellReuseIdentifier: "MoonTableViewCell")

        selfSizedTableView.delegate = self
        selfSizedTableView.dataSource = moonDataSource

        var frame = CGRect.zero
        frame.size.height = .leastNormalMagnitude
        selfSizedTableView.tableHeaderView = UIView(frame: frame)
        selfSizedTableView.tableFooterView = UIView(frame: frame)

        selfSizedTableView.maxHeight = 240.0
        selfSizedTableView.estimatedRowHeight = 40.0
        selfSizedTableView.rowHeight = UITableView.automaticDimension

        moonDataSource.data.addAndNotify(observer: self) { [weak self] in
            self?.selfSizedTableView.reloadData()
        }

        moonDataSource.data.value = data

    }

FYI the dataSource is a custom dataSource, with dynamic value (Generics) and the observer pattern, to reload the collection/tableView when the data is set.

I also have this warning when I launch the App.

[CollectionView] An attempt to update layout information was detected while already in the process of computing the layout (i.e. reentrant call). This will result in unexpected behaviour or a crash. This may happen if a layout pass is triggered while calling out to a delegate.

Any hints or advice on how I should handle this ?

Because I am facing a strange behavior, it's like my sizeForItem use random values. The UICollectionViewCell height is not the same than my UITableView intrinsic content size height.

If I have 2 rows within my UITableView, the UICollectionView is not always equal at this size. I really don't know how to achieve this...

Should I invalideLayout?

3

There are 3 best solutions below

1
On BEST ANSWER

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.
0
On

It is indeed very tricky, but I found a working way to solve this problem. As far as i know i got this from a chat app, where message bubble sizes are dynamic.

Here we go:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
                    sizeForItemAt indexPath: IndexPath) -> CGSize {

    // Minimum size
    let frame = CGRect(x: 0, y: 0, width: view.frame.width - 30, height: 0)
    let cell = MoonCollectionViewCell()

    // Fill it with the content it will have in the actual cell,
    // cell.content is just an example
    let cell.content = items[indexPath.item]
    cell.layoutIfNeeded()

    // Define the maximum size it can be
    let targetSize = CGSize(width: view.frame.width - 30, height: 240)
    let estimatedSize = cell.systemLayoutSizeFittingSize(tagetSize)

    return CGSize(width: view.frame.width - 30, height: estimatedSize.height)
}

What it basically do is, to define a minimum frame and the size that is targeted. Then by calling systemLayoutSizeFittingSize, it resizes the cell to the optimal size, but not larger than the targetSize.

Adjust the code to your needs, but this should work.

2
On

I tried to find the culprit in the posted code, but it seems that there are many moving parts. So, I will try to give some hints, that hopefully could help.

In theory (there is caveat for iOS 12), self sizing UICollectionViewCells should not be difficult. You essentially could set the collectionViewLayout.estimedItemSize to any value (preferred is the constant below), like this:

(collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.estimatedItemSize = UICollectionViewFlowLayout.automaticSize

Then you have to make sure the constraints in the cells are set in a way that it can self size; that is auto layout can calculate the width and the height of the cell. You are providing an intrinsicContentSize of the tableView and it is wrapped by its super view from all four ends, so this should be OK.

Once you set the estimatedItemSize as shown above, you should not implement the delegate method returning the size:

func collectionView(_: UICollectionView, layout: UICollectionViewLayout, sizeForItemAt: IndexPath) -> CGSize

A quick tutorial can be found here for further reference: https://medium.com/@wasinwiwongsak/uicollectionview-with-autosizing-cell-using-autolayout-in-ios-9-10-84ab5cdf35a2

As I said in theory it should not be difficult, but cell auto sizing seems broken on iOS 12 see here In iOS 12, when does the UICollectionView layout cells, use autolayout in nib

If I were in you position, I would start from afresh, adding complexity step by step:

  • try implement the self sizing cells, possibly with with a simple UIView and an override of intrinsicContentSize; possibly by using iOS 11.4 SDK to exclude issues relevant to iOS 12 (the easiest way is to download latest Xcode 9 and work from there); if not possible do the iOS 12 fixes at this step
  • replace the simple view with a table view (which may also have dynamic sizing per see)
  • do the tableview reload data flow, i.e. dynamic sizing feature
  • if everything OK, do the iOS 12 fixes and migrate to iOS 12

Hope this helps.

BTW, the warning in the console is probably due to call to layoutIfNeeded() in the delegate method. It triggers an immediate layout pass, whereas this is done for the UICollectionView once all sizes are collected.