8

I am loading a number of remote images with Kingfisher and having significant difficulty getting them to load correctly into a Tableview with cells of dynamic heights. My goal is to have the images always be the full width of the screen and of a dynamic height, how can this be achieved?

I asked a related question previously which led to understanding the basic layout using a stack view: SnapKit: How to set layout constraints for items in a TableViewCell programatically

So I've built something like the following:

Hierarchy overview

With the following code (some parts removed for brevity):

// CREATE VIEWS
let containerStack = UIStackView()
let header = UIView()
let headerStack = UIStackView()
let title = UILabel()
let author = UILabel()
var previewImage = UIImageView()

...

// KINGFISHER
let url = URL(string: article.imageUrl)
previewImage.kf.indicatorType = .activity
previewImage.kf.setImage(
  with: url,
  options: [
    .transition(.fade(0.2)),
    .scaleFactor(UIScreen.main.scale),
    .cacheOriginalImage
]) { result in
  switch result {
  case .success(_):
    self.setNeedsLayout()
    UIView.performWithoutAnimation {
      self.tableView()?.beginUpdates()
      self.tableView()?.endUpdates()
    }
  case .failure(let error):
    print(error)
  }
}

...

// LAYOUT
containerStack.axis = .vertical

headerStack.axis = .vertical
headerStack.spacing = 6
headerStack.addArrangedSubview(title)
headerStack.addArrangedSubview(author)
header.addSubview(headerStack)

containerStack.addArrangedSubview(header)
containerStack.addSubview(previewImage)

addSubview(containerStack)

headerStack.snp.makeConstraints { make in
  make.edges.equalToSuperview().inset(20)
}

containerStack.snp.makeConstraints { make in
  make.edges.equalToSuperview()
}

Without a constraint for imageView, the image does not appear.

With the following constraint, the image does not appear either:

previewImage.snp.makeConstraints { make in
  make.leading.trailing.bottom.equalToSuperview()
  make.top.equalTo(headerView.snp.bottom).offset(20)
}

With other attempts, the image is completely skewed or overlaps the labels/other cells and images.

Finally, following this comment: With Auto Layout, how do I make a UIImageView's size dynamic depending on the image? and this gist: https://gist.github.com/marcc-orange/e309d86275e301466d1eecc8e400ad00 and with these constraints make.edges.equalToSuperview() I am able to get the images to display at their correct scales, but they completely cover the labels.

Ideally it would look something like this:

mockup

GetSwifty
  • 6,831
  • 1
  • 26
  • 45
waffl
  • 4,140
  • 5
  • 60
  • 108
  • You aren't making constraints right. – El Tomato Mar 12 '19 at 21:29
  • @ElTomato yes I assume it is a problem with the constraint, combined with some complexity of remote images etc, do you have any advice on how to best proceed? I was also under the impression that StackViews were supposed to reduce the need for most constraints. – waffl Mar 12 '19 at 22:00
  • 2
    Replace the code you are showing with the code of your constraints. Kingfisher doesnt have anything to do with your issues. In general you can get rid of the outer stackview and the headerview. You might also want to read the docs about content hugging and compression resistance priority. – MartinM Mar 13 '19 at 11:21
  • @MartinM I'm sorry, I'm not entirely sure what you mean by 'Replace the code you are showing with the code of your constraints.' I feel like all the constraints I try are problematic. (or did you mean to hide the kingfisher code since it's not relevant). In the linked thread, the person mentioned to use a StackView, otherwise, how does it know to arrange the items inside the cell? – waffl Mar 13 '19 at 15:13
  • Add `aspect ratio` or `width` field in your service – SPatel Mar 18 '19 at 08:31
  • Your labels should also be of varying height ? – Awais Fayyaz Mar 20 '19 at 06:48
  • As said @MartinM you problem has nothing to do with Kingfisher. To implement different images heights, you should get width and height from API. Then according to aspect ratio (cellHeight = apiImageHeight * cellWidth / apiImageWidth) calculate new cell height and set it to image height constraint. – Bohdan Savych Mar 20 '19 at 07:00
  • Using SnapKit is a must or can we do it simply using AutoLayout ? – Awais Fayyaz Mar 20 '19 at 10:56
  • The way I do this: Add an equal-widths constraint to the `UIImageView` and the parent `stackView`, add an aspect ratio constraint (might have to be updated based on the specific images), and set the `UIImageView` scaling to `aspectFit`. – GetSwifty Mar 20 '19 at 18:06

3 Answers3

6

100 % working solution with Sample Code

I just managed to acheive the same layout with dynamic label contents and dynamic image dimensions. I did it through constraints and Autolayout. Take a look at the demo project at this GitHub Repository


As matt pointed out, we have to calculate the height of each cell after image is downloaded (when we know its width and height). Note that the height of each cell is calculated by tableView's delegate method heightForRowAt IndexPath

So after each image is downloaded, save the image in array at this indexPath and reload that indexPath so height is calculated again, based on image dimensions.

Some key points to note are as follows

  • Use 3 types of cells. One for label, one for subtitle and one for Image. Inside cellForRowAt initialize and return the appropriate cell. Each cell has a unique cellIdentifier but class is same
  • number of sections in tableView == count of data source
  • number of rows in section == 3
    • First row corresponds to title, second row corresponds to subtitle and the 3rd corresponds to the image.
  • number of lines for labels should be 0 so that height should be calculated based on content
  • Inside cellForRowAt download the image asynchrounously, store it in array and reload that row.
  • By reloading the row, heightForRowAt gets called, calculates the required cell height based on image dimensions and returns the height.
  • So each cell's height is calculated dynamically based on image dimensions

Take a look at Some code

override func numberOfSections(in tableView: UITableView) -> Int {
  return arrayListItems.count
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  //Title, SubTitle, and Image
  return 3
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

switch indexPath.row {
case 0:
  //configure and return Title Cell. See code in Github Repo 

case 1:

  //configure and return SubTitle Cell. See code in Github Repo

case 2:

  let cellImage = tableView.dequeueReusableCell(withIdentifier: cellIdentifierImage) as! TableViewCell
  let item = arrayListItems[indexPath.section]
  //if we already have the image, just show
  if let image = arrayListItems[indexPath.section].image {
    cellImage.imageViewPicture.image = image
  }else {

    if let url = URL.init(string: item.imageUrlStr) {

      cellImage.imageViewPicture.kf.setImage(with: url) { [weak self] result in
        guard let strongSelf = self else { return } //arc
        switch result {
        case .success(let value):

          print("=====Image Size \(value.image.size)"  )
          //store image in array so that `heightForRowAt` can use image width and height to calculate cell height
          strongSelf.arrayListItems[indexPath.section].image = value.image
          DispatchQueue.main.async {
          //reload this row so that `heightForRowAt` runs again and calculates height of cell based on image height
            self?.tableView.reloadRows(at: [indexPath], with: .automatic)
          }

        case .failure(let error):
          print(error) // The error happens
        }
      }

    }

  }


  return cellImage

default:
  print("this should not be called")
}

//this should not be executed
return .init()
}


override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
//calculate the height of label cells automatically in each section
if indexPath.row == 0 || indexPath.row == 1 { return UITableView.automaticDimension }

// calculating the height of image for indexPath
else if indexPath.row == 2, let image = arrayListItems[indexPath.section].image {

  print("heightForRowAt indexPath : \(indexPath)")
  //image

  let imageWidth = image.size.width
  let imageHeight = image.size.height

  guard imageWidth > 0 && imageHeight > 0 else { return UITableView.automaticDimension }

  //images always be the full width of the screen
  let requiredWidth = tableView.frame.width

  let widthRatio = requiredWidth / imageWidth

  let requiredHeight = imageHeight * widthRatio

  print("returned height \(requiredHeight) at indexPath: \(indexPath)")
  return requiredHeight


}
else { return UITableView.automaticDimension }
}

Related.

Another approach that we can follow is return the image dimensions from the API request. If that can be done, it will simplify things a lot. Take a look at this similar question (for collectionView).

Self sizing Collection view cells with async image downloading.

Placholder.com Used for fetching images asynchronously

Self Sizing Cells: (A Good read)

Sample

Sample

Awais Fayyaz
  • 2,029
  • 1
  • 17
  • 35
5

It’s relatively easy to do what you’re describing: your image view needs a width constraint that is equal to the width of the “screen” (as you put it) and a height constraint that is proportional to the width constraint (multiplier) based on the proportions of the downloaded image (aka “aspect ratio”). This value cannot be set in advance; you need to configure it once you have the downloaded image, as you do not know its proportions until then. So you need an outlet to the height constraint so that you can remove it and replace it with one that has the correct multiplier when you know it. If your other constraints are correct in relation to the top and bottom of the image view, everything else will follow as desired.

These screen shots show that this approach works:

enter image description here

(Scrolling further down the table view:)

enter image description here

It isn’t 100% identical to your desired interface, but the idea is the same. In each cell we have two labels and an image, and the images can have different aspect ratios but those aspect ratios are correctly displayed - and the cells themselves have different heights depending upon that.

This is the key code I used:

    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! Cell
    // in real life you’d set the labels here too
    // in real life you’d be fetching the image from the network...
    // ...and probably supplying it asynchronously later
    let im = UIImage(named:self.pix[indexPath.row])!
    cell.iv.image = im
    let con = cell.heightConstraint!
    con.isActive = false
    let ratio = im.size.width/im.size.height
    let newcon = NSLayoutConstraint(item: con.firstItem, attribute: con.firstAttribute, relatedBy: con.relation, toItem: con.secondItem, attribute: con.secondAttribute, multiplier: ratio, constant: 0)
    newcon.isActive = true
    cell.heightConstraint = newcon
    return cell
matt
  • 447,615
  • 74
  • 748
  • 977
  • And as others have said, this is kind of a silly use of a stack view so I would advise just getting rid of it. – matt Mar 15 '19 at 16:38
  • Thank you for responding, so the outer stack view should not be used? Would the ImageView then also need constraints to be placed below the bottom of the labels then? (just a reminder, these are all inside TableViewCells in case it matters) – waffl Mar 15 '19 at 17:12
  • 1
    Added screen shots and some code. In real life things would be a bit more complex for you because you’d be obtaining the images asynchronously, but as others have pointed out, that is not relevant to problem you are asking about. – matt Mar 15 '19 at 17:32
  • Adding and removing constraints is heavy operations, it's way better to calculate desire height by hands (since you already have ratio and width) and just update constant on height constraint – ManWithBear Mar 21 '19 at 22:00
  • @ManWithBear That’s just false. There is a wonderful WWDC 2028 video on constraint performance. Watch it. – matt Mar 21 '19 at 22:24
  • @matt Sorry but you wrong here. Adding and removing constraints is just redundant work: https://developer.apple.com/videos/play/wwdc2018/220?time=597 While changing constant is very cheap and fast https://developer.apple.com/videos/play/wwdc2018/220?time=1341 – ManWithBear Mar 21 '19 at 22:44
3

There's a straight forward solution for your problem if you don't want to change your layout.

1- define your cell

2- put the UIImageView and other UI elements you like inside your cell and add these constraints for the image view: -top,leading,trailing,bottom to superview -height constraints and add outlet to your code (for example :heightConstraint)

enter image description here

3-Change the content fill to : aspect fit

4- Load your images via kingfisher or any other way you like, once you pass your image, check the size and calculate the ratio : imageAspectRatio = height/width

5-Set the heightConstraint.constant = screenWidth * imageAspectRatio

6-call layoutIfNeeded() for the cell and you should be ok!

*This solution works with any UI layout composition including stack views, the point is having a constraint on the images and letting the tableview figure it out how to calculate and draw constraints.

class CustomTableViewCell: UITableViewCell {

    @IBOutlet weak var heightConstraint: NSLayoutConstraint!
    @IBOutlet weak var sampleImageView: UIImageView!

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

    func configure(image:UIImage) {
        let hRatio = image.size.height / image.size.width
        let newImageHeight = hRatio * UIScreen.main.bounds.width
        heightConstraint.constant = newImageHeight
        sampleImageView.image = image
        sampleImageView.layoutIfNeeded()
    }
}

Result : enter image description here

Amir.n3t
  • 1,594
  • 12
  • 25