iosswiftuitableviewuicollectionviewautolayout

Problem with height calculation automatically to UICollectionView inside a UITableViewCell


Good?

I have a problem with the implementation of a UICollectionview intro a UITableViewCell, because the height calculation of this component does not work properly, even though using updateConstraints() and called by delegate tableView.beginUpdates(), sometimes works correctly and sometimes renders missing height.

correctly render correctly render

wrong render wrong render

My layout is a table with a lot of cells, these cells have some texts and items in two collectionviews (I'm using UICollectionView because the size of these items needs to be dynamically)

The implementation of the UITableViewCell:

protocol CardTableViewCellDelegate: AnyObject {
    func updateTableView()
}

class CardTableViewCell: UITableViewCell {
    static let identifier: String = "CardTableViewCell"
    
    lazy var cardView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.layer.cornerRadius = 8
        view.layer.masksToBounds = true
        view.clipsToBounds = true
        view.layer.borderWidth = 1
        view.layer.borderColor = UIColor.separator.cgColor
        
        return view
    }()
    
    lazy var containerStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.axis = .vertical
        stackView.spacing = 8
        
        return stackView
    }()
    
    lazy var title: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .headline)
        label.numberOfLines = 0
        
        return label
    }()
    
    lazy var subtitle: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .subheadline)
        label.numberOfLines = 0
        
        return label
    }()
    
    lazy var sourceNew: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .footnote)
        label.numberOfLines = 0
        
        return label
    }()
    
    var assetsCollectionView: AssetsCollectionView = {
        let collectionView = AssetsCollectionView()
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        return collectionView
    }()
    
    lazy var benchmarksCollectionView: BenchmarksCollectionView = {
        let collectionView = BenchmarksCollectionView()
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        return collectionView
    }()

    weak var delegate: CardTableViewCellDelegate?
    
    // MARK: - Init
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Methods
    func configure(with card: Card) {
        title.text = card.title
        subtitle.text = card.subtitle
        sourceNew.text = card.sourceNew
        
        
        configureAssetsCollectionView(with: card.assets)
        configurebenchmarksCollectionView(with: card.benchmarks)
    }
    
    private func configureAssetsCollectionView(with model: [Asset]?) {
        if let assets = model {
            assetsCollectionView.configure(with: assets)
            
            containerStackView.addArrangedSubview(assetsCollectionView)
            
            assetsCollectionView.updateConstraints()
            delegate?.updateTableView()
        }
    }
    
    private func configurebenchmarksCollectionView(with model: [Benchmark]?) {
        if let benchmarks = model {
            benchmarksCollectionView.configure(with: benchmarks)
            
            containerStackView.addArrangedSubview(benchmarksCollectionView)
        }
    }
}

// MARK: - ViewCode
extension CardTableViewCell {
    func setupView() {
        setupLayout()
        setupHierarchy()
        setupConstrains()
    }
    
    func setupLayout() {
        backgroundColor = .white
        cardView.backgroundColor = .clear
    }
    
    func setupHierarchy() {
        [title,
         subtitle,
         sourceNew].forEach(containerStackView.addArrangedSubview(_:))
        
        cardView.addSubview(containerStackView)
        
        contentView.addSubview(cardView)
    }
    
    func setupConstrains() {
        NSLayoutConstraint.activate([
            cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
            cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
            cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16),
            
            containerStackView.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 8),
            containerStackView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 8),
            containerStackView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -8),
            containerStackView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -8),
            
            assetsCollectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 22),
            benchmarksCollectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 22)
        ])
    }
}

and, the UICollectionView:

import UIKit

class AssetsCollectionView: UICollectionView {
    private var assets: [Asset] = []
    
    override var intrinsicContentSize: CGSize {
        self.layoutIfNeeded()
        print(contentSize)
        return self.contentSize
    }
    
    init() {
        let layout = LeftAlignedCollectionViewFlowLayout()
        layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
        layout.minimumLineSpacing = 8
        layout.minimumInteritemSpacing = 8
        super.init(frame: .zero, collectionViewLayout: layout)
        
        delegate = self
        dataSource = self
        
        register(AssetCollectionViewCell.self,
                 forCellWithReuseIdentifier: AssetCollectionViewCell.identifier)
        
        showsHorizontalScrollIndicator = false
        showsVerticalScrollIndicator = false
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(with assets: [Asset]) {
        self.assets = assets
        reloadData()
    }
}

extension AssetsCollectionView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return assets.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView
            .dequeueReusableCell(withReuseIdentifier: AssetCollectionViewCell.identifier, for: indexPath) as? AssetCollectionViewCell else {
            return UICollectionViewCell()
        }
        
        cell.configure(with: assets[indexPath.row])
        
        return cell
    }
}

So, can this component calculate your height automatically without using height calculation tricks? I tried using a lot of methods to send the cell to recalculate your constraints, but no one worked. :(

I know that I can use the number of items to calculate the height, but I need more powerful code to handle possible causes... any anyone help me with this?

The github url to reproduce locally: https://github.com/ramonfsk/MarketContextTimeline.git


Solution

  • You'll run into a number of issues trying to use a "self-sizing" collection view.

    Collection views are designed to layout the cells based on the frame - not the other way around.

    If we look at your LeftAlignedCollectionViewFlowLayout (this is what happens whether using a subclassed layout or not):

    `layoutAttributesForElements(in rect: CGRect)`
    

    is called.

    If we start the layout with:

    assetsCollectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 22)
    

    the rect in layoutAttributesForElements(in rect: CGRect) will be:

    (0.0, 0.0, 353.0, 22.0)  // the width will vary
    

    Let's say we're using these strings:

            "MGLU3", "IBVV11", "BBSA3", "Slightly Longer String",
            "FIVE", "SIX", "SEVEN", "EIGHT",
            "NINE", "TEN", "ELEVEN", "TWELVE",
            "Hash Index Ethereum position replicated with a 100% accuracy",
            "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN",
    

    and the goal is this:

    goal

    The layout starts starts looking like this:

    step1

    Let's add a red rectangle showing the initial frame:

    step2

    and a green bracket showing the current collectionView.contentSize.height:

    step3

    So, let's expand the collection view frame to show all the cells:

    step4

    Note that the contentSize.height does not reflect all the cells... and it can vary wildly depending on the actual cell sizes, initial frame, etc. We don't get a valid (for our purposes) contentSize.height until all of the cells have been laid-out by the collection view.

    Even if we jump through a bunch of hoops to "force" all of the cells to be rendered, we still have timing issues... the collection view can't layout the cells until it knows its width, and since it's embedded in a table view cell we have to make "call-backs" to auto-size the cell heights.

    In addition -- let's use your code but explicitly set the height of the collection view to show all the cells:

    step5

    Looks good -- except cells are reused... so let's scroll up and down a bit:

    step6

    Yeah, clearly not acceptable.

    So... I'd suggest using a UIView subclass that lays-out the labels on its own.

    The general logic is:

    We'll start with a "padded label view" that is roughly equivalent to a cell:

    class PaddedLabelView: UIView {
        
        let theLabel = UILabel()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
    
            theLabel.translatesAutoresizingMaskIntoConstraints = false
            addSubview(theLabel)
            let edgeConstraints: [NSLayoutConstraint] = [
                theLabel.topAnchor.constraint(equalTo: topAnchor, constant: 2.0),
                theLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
                theLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
                theLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2.0),
            ]
            // this prevents auto-layout complaints
            edgeConstraints[2].priority = .required - 1
            edgeConstraints[3].priority = .required - 1
            NSLayoutConstraint.activate(edgeConstraints)
    
            // properties
            theLabel.numberOfLines = 0
            theLabel.textColor = .white
            theLabel.font = .systemFont(ofSize: 12.0, weight: .bold)
            backgroundColor = .systemBlue
            layer.cornerRadius = 6.0
            layer.masksToBounds = true
        }
    }
    

    and now we create an "arranged views" class:

    class BasicArrangedViewsView: UIView {
        
        public var theStrings: [String] = [] {
            didSet {
                // remove existing views
                for v in labelViews { v.removeFromSuperview() }
                labelViews = []
                for str in theStrings {
                    let t = PaddedLabelView()
                    t.theLabel.text = str
                    addSubview(t)
                    labelViews.append(t)
                }
                calcFrames(bounds.width)
            }
        }
        
        // horizontal space between label views
        let interItemSpace: CGFloat = 8.0
        
        // vertical space between "rows"
        let lineSpace: CGFloat = 8.0
        
        // we use these to set the intrinsic content size
        private var myHeight: CGFloat = 0.0
        private var myWidth: CGFloat = 0.0
        
        private var myHC: NSLayoutConstraint!
        
        private var labelViews: [PaddedLabelView] = []
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            // initialize height constraint, but don't activate it yet
            myHC = heightAnchor.constraint(equalToConstant: 0.0)
            myHC.priority = .required - 1
        }
        
        func calcFrames(_ tagetWidth: CGFloat) {
            // this can be called multiple times, and
            //  may be called before we have a frame
            if tagetWidth == 0.0 {
                return
            }
            
            var newWidth: CGFloat = 0.0
            var newHeight: CGFloat = 0.0
            
            var x: CGFloat = 0.0
            var y: CGFloat = 0.0
            
            var isMultiLine: Bool = false
            var thisRect: CGRect = .zero
            
            for thisView in labelViews {
                // start with NOT needing to wrap
                isMultiLine = false
                // set the frame width to a very wide value, so we get the non-wrapped size
                thisView.frame.size.width = 5000
                thisView.layoutIfNeeded()
                var sz: CGSize = thisView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
                sz.width = ceil(sz.width)
                sz.height = ceil(sz.height)
                thisRect = .init(x: x, y: y, width: sz.width, height: sz.height)
                // if this item is too wide to fit on the "row"
                if thisRect.maxX > tagetWidth {
                    // if this is not the FIRST item on the row
                    //  move down a row and reset x
                    if x > 0.0 {
                        x = 0.0
                        y = thisRect.maxY + lineSpace
                    }
                    thisRect = .init(x: x, y: y, width: sz.width, height: sz.height)
                    // if this item is still too wide to fit, that means
                    //  it needs to wrap the text
                    if thisRect.maxX > tagetWidth {
                        isMultiLine = true
                        // this will give us the height based on max available width
                        sz = thisView.systemLayoutSizeFitting(.init(width: tagetWidth, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
                        sz.width = ceil(sz.width)
                        sz.height = ceil(sz.height)
                        // update the frame
                        thisView.frame.size = sz
                        thisView.layoutIfNeeded()
                        // this will give us the width needed for the wrapped text (instead of the max available width)
                        sz = thisView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
                        sz.width = ceil(sz.width)
                        sz.height = ceil(sz.height)
                        thisRect = .init(x: x, y: y, width: sz.width, height: sz.height)
                    }
                }
                // if we needed to wrap the text, adjust the next Y and reset X
                if isMultiLine {
                    x = 0.0
                    y = thisRect.maxY + lineSpace
                }
                thisView.frame = thisRect
                // update the max width var
                newWidth = max(newWidth, thisRect.maxX)
                // if we did NOT need to wrap lines, adjust the X
                if !isMultiLine {
                    x += sz.width + interItemSpace
                }
            }
            
            newHeight = thisRect.maxY
            
            if myWidth != newWidth || myHeight != newHeight {
                myWidth = newWidth
                myHeight = newHeight
                // don't activate the constraint if we're not in an auto-layout case
                if self.translatesAutoresizingMaskIntoConstraints == false {
                    myHC.isActive = true
                }
                // update the height constraint constant
                myHC.constant = myHeight
                invalidateIntrinsicContentSize()
            }
        }
        
        override var intrinsicContentSize: CGSize {
            return .init(width: myWidth, height: myHeight)
        }
        override func invalidateIntrinsicContentSize() {
            super.invalidateIntrinsicContentSize()
            
            // walk-up the view hierarchy...
            // this will handle self-sizing cells in a table or collection view, without
            //  the need to "call back" to the controller
            var sv = superview
            while sv != nil {
                if sv is UITableViewCell || sv is UICollectionViewCell {
                    sv?.invalidateIntrinsicContentSize()
                    sv = nil
                } else {
                    sv = sv?.superview
                }
            }
        }
        
        override var bounds: CGRect {
            willSet {
                if newValue.width != bounds.width {
                    calcFrames(newValue.width)
                }
            }
        }
        
    }
    

    and a sample controller:

    class BasicVC: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .systemBackground
            
            // sample strings
            let strs: [String] = [
                "MGLU3", "IBVV11", "BBSA3", "Slightly Longer String",
                "FIVE", "SIX", "SEVEN", "EIGHT",
                "NINE", "TEN", "ELEVEN", "TWELVE",
                "Hash Index Ethereum position replicated with a 100% accuracy",
                "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN",
            ]
    
            let aView = BasicArrangedViewsView()
            
            aView.theStrings = strs
            
            aView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(aView)
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                aView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                aView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                aView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                
                // don't set a bottom or height constraint
            ])
            
            // so we can see the view frame
            aView.backgroundColor = .systemYellow
        }
        
    }
    

    The result:

    step7

    and how it looks implemented in your project:

    step8