0

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)
        }
    }
}
lithium
  • 1,272
  • 1
  • 14
  • 28
  • It seems that I can solve some or even most of these issues by implementing heightForRowAt but I just want to know why current approach seems to be not working here. – lithium May 18 '20 at 15:33
  • 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? – Joshua May 18 '20 at 16:44
  • I am going to assume in the refreshCell method you are using `beginUpdates` and `endUpdates` to make sure the table view knows? – Rikh May 18 '20 at 17:04
  • @Joshua thanks for advice. I haven't tried contentView, will do. Right now, `refreshcell` is doing nothing, I tried to play with `setNeedsLayout` and `layoutIfNeeded` and so on, but to no avail. – lithium May 18 '20 at 18:34
  • @Rikh I've tried to use this approach in my real project but it seems there is no easy way for me to use `beginUpdates` and `endUpdates` because my `tableView` is quite dynamic, there could be a new element at any moment, so I've got a lot of `out of index` errors. – lithium May 18 '20 at 18:36
  • what is your dMessage struct ? – Jawad Ali May 18 '20 at 18:38
  • @jawadAli in this example it's just a `struct` with two variables: text (`String`) and imageHeight (`CGFloat`). – lithium May 18 '20 at 18:43
  • @Joshua thanks a lot, `setNeedsDisplay` seems to work. I still don't understand why my layout is completely broken without using it though, but if you post your commentary as an answer I'll mark this as correct. Thanks again. – lithium May 18 '20 at 18:45

1 Answers1

1

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.

Joshua
  • 2,432
  • 1
  • 20
  • 29