Search code examples
iosswiftsnapkit

Swift - Difficulty with different sized images in TableViewCell


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


Solution

  • 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