iosswiftuitableviewuikituitableviewautomaticdimension

Async height change for UITableViewCell


In one of my projects, I need to change the height of UIImageView in UITableViewCell according to image size, but the problem is that sometimes I have to do this after the cell is already shown.

So, my current solution works like a charm if I know all the image sizes beforehand, but if I'm trying to calculate this with some delay – it's completely broken (especially with scrolling but it's broken even without it).

I made the example project to illustrate this. There is no async downloading, but I'm trying to dynamically change the height of UIImageView after some delay (1s). The height depends on UIImageView, so every next UIImageView should be slightly higher (10 pixels) than previous one. Also, I have a UILabel, constrained to UIImageView.

It looks like that (UIImageViews are the red ones)

without async it works

If I'm trying to do this async, it looks like this, all the UILabels are really broken here.

async: before scroll

and this is one after the scroll (async too):

async: after scroll

What am I doing wrong here? I've read several threads about dynamic heights, but none of the solutions worked for me yet.

My code is fairly simple:

func addTableView() {
    tableView = UITableView()
    tableView.translatesAutoresizingMaskIntoConstraints = false
    tableView.dataSource = self
    tableView.delegate = self
    tableView.separatorStyle = .none
    tableView.estimatedRowHeight = 100
    tableView.rowHeight = UITableView.automaticDimension
    tableView.backgroundColor = .black
    tableView.register(DynamicCell.self, forCellReuseIdentifier: "dynamicCell")
    view.addSubview(tableView)

    tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true
    tableView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true
    tableView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true
    tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true

}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "dynamicCell", for: indexPath) as! DynamicCell
        cell.message = messageArray[indexPath.row]
        cell.backgroundColor = .clear
        cell.selectionStyle = .none
        cell.buildCell()
    return cell
}

DynamicCell.swift (delegate is doing nothing right now):

var backView: UIView!
var label: UILabel!
var picView: UIImageView!

var message: DMessage?
var picViewHeight: NSLayoutConstraint!

var delegate: RefreshCellDelegate?

override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)

    backView = UIView()
    backView.translatesAutoresizingMaskIntoConstraints = false
    backView.backgroundColor = .white
    backView.clipsToBounds = true
    backView.layer.cornerRadius = 8.0
    self.addSubview(backView)

    label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.textAlignment = .left
    label.textColor = .black
    label.numberOfLines = 0
    backView.addSubview(label)

    picView = UIImageView()
    picView.translatesAutoresizingMaskIntoConstraints = false
    picView.clipsToBounds = true
    picView.backgroundColor = .red
    backView.addSubview(picView)

    addMainConstraints()

}

func addMainConstraints() {
    backView.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 8).isActive = true
    backView.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -32).isActive = true
    backView.topAnchor.constraint(equalTo: self.topAnchor, constant: 4).isActive = true
    backView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -4).isActive = true

    picView.topAnchor.constraint(equalTo: backView.topAnchor, constant: 0).isActive = true
    picView.leftAnchor.constraint(equalTo: backView.leftAnchor, constant: 0).isActive = true
    picView.rightAnchor.constraint(equalTo: backView.rightAnchor, constant: 0).isActive = true

    label.topAnchor.constraint(equalTo: picView.bottomAnchor, constant: 0).isActive = true
    label.leftAnchor.constraint(equalTo: backView.leftAnchor, constant: 8).isActive = true
    label.rightAnchor.constraint(equalTo: backView.rightAnchor, constant: -8).isActive = true
    label.bottomAnchor.constraint(equalTo: backView.bottomAnchor, constant: -4).isActive = true

    picViewHeight = NSLayoutConstraint(item: picView, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 100)
    picViewHeight.priority = UILayoutPriority(999)
    picViewHeight.isActive = true

}

override func prepareForReuse() {
    picViewHeight.constant = 0
    //picViewHeight.constant = 0
}

func buildCell() {
    guard let message = message else {return}
    label.attributedText = NSAttributedString(string: message.text)
    changeHeightWithDelay()
    //changeHeightWithoutDelay()
}

func changeHeightWithoutDelay() {
    if let nh = self.message?.imageHeight {
        self.picViewHeight.constant = nh
        self.delegate?.refreshCell(cell: self)
    }
}

func changeHeightWithDelay() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        if let nh = self.message?.imageHeight {
            self.picViewHeight.constant = nh
            self.delegate?.refreshCell(cell: self)
        }
    }
}

Solution

  • putting this as an answer.

    one thing I noticed, when you are playing around with cell, it's always better to use the contentView instead of directly using self. ie self.contentView.addSubview(). what does refreshcell function do? have you tried marking it as needsSetDisplay so in the next draw cycle it will be updated? have you tried calling layoutIfNeeded?

    To explain a bit further, your view has already been 'rendered' the moment you want to change the height/width of your view you need to inform it that there's an update. this happens when you mark the view as setNeedsDisplay and in the next render cycle it will be updated

    more info on apple's documentation here

    You can use this method or the setNeedsDisplay(_:) to notify the system that your view’s contents need to be redrawn. This method makes a note of the request and returns immediately. The view is not actually redrawn until the next drawing cycle, at which point all invalidated views are updated.