1

I'm making a messaging app and determining the message cell's height based on the estimated frame of one of the cell's labels. For some reason, on smaller devices running an OS prior to iOS 13, the height returned is much larger than needed.

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let message = messageArr[indexPath.item].messageBody 
        let size = CGSize(width: collectionView.frame.width - 120, height: 1000)            
        let options = NSStringDrawingOptions.usesFontLeading.union(.usesLineFragmentOrigin)
        var estimatedFrame = CGRect()
        var cellLbl: UILabel!
        var personLbl: UILabel!

        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CurrentUserMessageCell", for: indexPath) as? CurrentUserMessageCell {
            cellLbl = cell.messageLbl
            personLbl = cell.personLbl
            cell.translatesAutoresizingMaskIntoConstraints = false
        }

        if let font = cellLbl.font {
            estimatedFrame = NSString(string: message).boundingRect(with: size, options: options, attributes: [NSAttributedString.Key.font: font], context: nil)
        }

        let height = estimatedFrame.height + personLbl.frame.height + 16
        return CGSize(width: collectionView.frame.width, height: height)
}

This worked great on all devices, prior to me changing the constraints of the label so it didn't always take up most of the available width, even with short messages. That's where collectionView.frame.width - 120 came from (width - insets, leading/trailing space).

I've narrowed the issue down to the estimated width of the label (determined in the size property) - but why is an inaccurate value not affecting iOS 13.1 as much as 12.2? How can I more accurately determine this value? I've tried offsetting the width by the label's insets and leading/trailing space, but the cell height is always too short then (characters get cut off prior to iOS 13.1, while 13.1 just loses a little padding).

This is on iOS 13.1 using the above code:

ios 13

and 12.2:

enter image description here

froggomad
  • 1,747
  • 2
  • 17
  • 40
  • I would look into the context, I bet some defaults about it changed across the major OS versions https://developer.apple.com/documentation/foundation/nsstring/1524729-boundingrect – Catalyst Nov 19 '19 at 14:55

2 Answers2

3

You have some problem in your code:

  • This isn't correct to call dequeueReusableCell outside of cellForItemAt.
  • Inside sizeForItemAt cell still not created, so don't try to access it.

... but why is an inaccurate value not affecting iOS 13.1 as much as 12.2?

There is numerous of factor, maybe it's just random. Maybe because you are creating cell inside sizeForItem or your logic of calculation of estimated size are not correct for some cases. Try to remove dequeueReusableCell and use function from below for calculation.


You can add this extension to your String to calculate width or height for you cell: additional info

extension String {

    func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)

        return ceil(boundingBox.height)
    }

    func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)

        return ceil(boundingBox.width)
    }
}

Inside sizeForItemAt (example of using)

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let message = messageArr[indexPath.item].messageBody

    // if you want the cell to take the entire width of the collectionView
    // then you can use this width as constrained width
    let estimatedCellWidth = collectionView.frame.width

    // If you want your label inside `cell` have a margin from each side
    // you can add some value to `estimatedHeight` and make it bigger
    // like: estimatedHeight + *some value*, to make final height of cell bigger
    let estimatedHeight = message.height(withConstrainedWidth: estimatedCellWidth, font: /*your current font*/)

    // Some additional logic of calculation if needed ...

    return CGSize(width: estimatedCellWidth, height: estimatedHeight)
}

Hope this is clear to you and helps you!

vpoltave
  • 1,612
  • 3
  • 14
  • 31
  • Thanks very much, this makes sense that the cell isn't available in `sizeForItemAt`. But the label's font property is which I need to determine the estimated height. The problem with using the method you outlined is that the width of the label is dynamic based on content size and height of the label. The cell's width is the only width that's known since I want the cell to take the entire width of the collectionView – froggomad Nov 19 '19 at 15:46
  • @froggomad I edited my answer a little, check comments inside `sizeForItemAt` maybe they will help you. Hope I make it clear – vpoltave Nov 19 '19 at 16:05
  • @froggomad And about label and it *dynamic width* - you can constraint your label to cell `contentView` with margins(top, bottom, left and right) and than it will grow up depend on size of you cell. – vpoltave Nov 19 '19 at 16:09
  • I want the cell to take the entire width of the collectionView (to prevent other cells from sharing the "row" its on) but the label inside the cell to only take as much space as necessary to fit its content – froggomad Nov 19 '19 at 16:14
1

I'd suggest using auto-layout instead of manually calculating cell heights.

There is a pretty good tutorial here (not mine): https://medium.com/@andrea.toso/uicollectionviewcell-dynamic-height-swift-b099b28ddd23

Here is a simple example (all via code - no @IBOutlets)... Create a new UIViewController and assign its custom class to AutoSizeCollectionViewController:

//
//  AutoSizeCollectionViewController.swift
//
//  Created by Don Mag on 11/19/19.
//

import UIKit

private let reuseIdentifier = "MyAutoCell"

class MyAutoCell: UICollectionViewCell {

    let personLbl: UILabel = {
        let label = UILabel()
        return label
    }()
    let cellLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        return label
    }()

    // used in systemLayoutSizeFitting() for auto-sizing cells
    lazy var width: NSLayoutConstraint = {
        let width = contentView.widthAnchor.constraint(equalToConstant: bounds.size.width)
        width.isActive = true
        return width
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .clear
        self.setupViews()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepareForReuse() {

    }

    private func setupViews() {

        contentView.backgroundColor = .lightGray

        contentView.layer.cornerRadius = 16.0
        contentView.layer.masksToBounds = true

        // we'll be using contentView ... so disable translates...
        contentView.translatesAutoresizingMaskIntoConstraints = false

        contentView.addSubview(personLbl)
        contentView.addSubview(cellLabel)

        [personLbl, cellLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.backgroundColor = .clear
            $0.textColor = .black
            $0.font = UIFont.systemFont(ofSize: 17.0, weight: .bold)
        }

        let g = contentView.layoutMarginsGuide

        // top / bottom padding
        let vPadding: CGFloat = 6.0

        // leading / trailing padding
        let hPadding: CGFloat = 8.0

        // vertical space between labels
        let vSpacing: CGFloat = 6.0

        NSLayoutConstraint.activate([

            // constrain personLbl to top, leading, trailing (with vPadding)
            personLbl.topAnchor.constraint(equalTo: g.topAnchor, constant: vPadding),

            // constrain personLbl to leading and trailing (with hPadding)
            personLbl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: hPadding),
            personLbl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -hPadding),

            // constrain cellLabel top to personLbl bottom (with spacing)
            cellLabel.topAnchor.constraint(equalTo: personLbl.bottomAnchor, constant: vSpacing),

            // constrain cellLabel to leading and trailing (with hPadding)
            cellLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: hPadding),
            cellLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -hPadding),

            // constrain cellLabel to bottom (with vPadding)
            cellLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -vPadding),

        ])

    }

    // used for auto-sizing collectionView cell
    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        width.constant = bounds.size.width
        return contentView.systemLayoutSizeFitting(CGSize(width: targetSize.width, height: 1))
    }

}

class AutoSizeCollectionViewController: UIViewController {

    // some sample data
    let theData: [[String]] = [
        ["Joe", "Hi"],
        ["Bob", "Hi back!"],
        ["Joe", "This is a message"],
        ["Bob", "This is a longer message. How does it look?"],
        ["Joe", "Oh yeah? Well, I can type a message that's even longer than yours. See what I mean?"],
        ["Bob", "Well good for you."],
    ]

    var theCollectionView: UICollectionView!
    var theFlowLayout: UICollectionViewFlowLayout!

    override func viewDidLoad() {
        super.viewDidLoad()

        // create vertical flow layout with 16-pt line spacing
        let flowLayout = UICollectionViewFlowLayout()
        flowLayout.scrollDirection = .vertical
        flowLayout.minimumInteritemSpacing = 0.0
        flowLayout.minimumLineSpacing = 16.0

        // this will be modified in viewDidLayoutSubviews()
        // needs to be small enough to fit on initial load
        flowLayout.estimatedItemSize = CGSize(width: 40.0, height: 40.0)

        theFlowLayout = flowLayout

        // create a collection view
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        collectionView.alwaysBounceVertical = true
        collectionView.backgroundColor = .clear

        collectionView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(collectionView)

        let g = view.safeAreaLayoutGuide

        NSLayoutConstraint.activate([

            // constraints for the collection view
            // for this example, 100-pts from top, 40-pts from bottom, 80-pts on each side
            collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 100.0),
            collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
            collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 80.0),
            collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -80.0),

        ])

        theCollectionView = collectionView

        // Register cell classes
        theCollectionView.register(MyAutoCell.self, forCellWithReuseIdentifier: reuseIdentifier)

        theCollectionView.dataSource = self
        theCollectionView.delegate = self

    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // collection view frame has now been determined by auto-layout
        // so set estimated item width to collection view frame width
        // height is approximate, as it will be auto-determined by the cell content (the labels)
        theFlowLayout.estimatedItemSize = CGSize(width: theCollectionView.frame.width, height: 100.0)
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        // reset estimated item size width to something small
        // to avoid layout issues on size change (such as device rotation)
        // it will be properly set again in viewDidLayoutSubviews()
        theFlowLayout.estimatedItemSize = CGSize(width: 40.0, height: 40.0)
    }

}

// MARK: UICollectionViewDataSource
extension AutoSizeCollectionViewController: UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return theData.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! MyAutoCell

        cell.personLbl.text = theData[indexPath.item][0].uppercased()
        cell.cellLabel.text = theData[indexPath.item][1]

        return cell
    }

}

// MARK: UICollectionViewDelegate

extension AutoSizeCollectionViewController: UICollectionViewDelegate {
    // delegate methods here...
}

Result:

enter image description here

DonMag
  • 69,424
  • 5
  • 50
  • 86