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)
If I'm trying to do this async, it looks like this, all the UILabels are really broken here.
and this is one after the scroll (async too):
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)
}
}
}
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.