read

This is a common scenario we will use as an example: Creating a table view that display images of different sizes, loading the images from the network and resizing the table view cells automatically.

We will do this programmatically (no storyboard), in Swift, and also make use of 2 helpful libraries:

  1. Reusable - Instead of messing with cell identifiers (Strings!) for UITableViewCell/etc, you can safely and conveniently use this mixin.
  2. SDWebImage - Asynchrously downloads images with caching

Setup

In viewDidLoad, setup the table view.

private func setupTableView() {
    tableView.register(cellType: MyTableViewCell.self)
    tableView.rowHeight = UITableViewAutomaticDimension
    tableView.estimatedRowHeight = 100 // Just an estimated value for calculating scroll indicator
}

The cell class is MyTableViewCell, and behold the beauty of registering without a cell identifier string. This is brought to you by Reusable.

The topic of auto adjusting UITableViewCell height with autolayout is not new. I have previously written the steps to doing that. And in the setup, I have done that by setting the rowHeight and estimatedRowHeight.

The Table View Cell

MyTableViewCell looks like this.

class MyTableViewCell: UITableViewCell, NibReusable {
  private var urlString: String?
  @IBOutlet weak var theImageView: UIImageView!
  @IBOutlet weak var theImageViewHeightConstraint: NSLayoutConstraint!
}

Remember we said we will be using Reusable? How it works is simply extending the cell with NibReusable. That’s all.

Okay, there is a Xib that goes along with this. The cell is not fully programmatically created. Because it is easier to use autolayout in the xib.

What is important is that there is a theImageViewHeightConstraint, which will will change when the image is downloaded.

The method to set the image

The cell provides a method to set the image URL string.

func setImage(withUrlString urlString: String, completion: @escaping () -> Void) {
    // Need to store the URL because cells will be reused. The check is in adjustBannerHeightToFitImage.
    self.urlString = urlString

    // Flush first. Or placeholder if you have.
    bannerImageView.image = nil

    guard let url = URL(string: bannerUrlString) else { return }

    // Loads the image asynchronously
    bannerImageView.sd_setImage(with: url) { [weak self] (image, error, cacheType, url) in
        self?.adjustHeightToFitImage(image: image, url: url, completion: completion)
    }
}

We split the code up with adjustHeightToFitImage, which calculate the image aspect ratio, and adjust the height (while occupying full/fixed width in the cell).

private func adjustHeightToFitImage(image: UIImage?, url: URL?, completion: @escaping () -> Void) {
    guard let bannerUrlString = bannerUrlString, bannerUrlString == url?.absoluteString else { return }
    guard let image = image else { return }

    let aspectRatio = image.size.width / image.size.height
    let bannerHeightToFit = bannerImageView.bounds.size.width / aspectRatio

    if bannerImageViewHeightConstraint.constant != bannerHeightToFit {
        bannerImageViewHeightConstraint.constant = bannerHeightToFit
        completion()
    }
}

The completion is necessary for the table view to know that the image is downloaded, and the height is adjusted.

Configuring the cell

We will omit the unnecessary code in the table view. What is most important is in the cell configuration.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // Dequeue without cell identifer!
    let cell: MyTableViewCell = tableView.dequeueReusableCell(for: indexPath)

    cell.setImage(withUrlString: imageUrlString, completion: { [weak self] in
        // A trick. The begin/end calls will reload just the height.
        self?.tableView.beginUpdates()
        self?.tableView.endUpdates()
    })

    return cell
}

In the completion handler, we did a trick to reload just the heights for the table view. This is more efficient than reloadData or reloadRows(at:with:).

The Problem with estimatedRowHeight

There is a major bug with using estimatedRowHeight (a feature of iOS 7, yet still not fixed after 2 years).

It does not scrollToRow correctly.

Hence, if you want to use scrollToRow, then go use the archaic approach since the beginning of iOS - implement heightForRowAt.

Table Section Header/Footer

Read this post on adding header/footer.

With Reusable, register the view.

tableView.register(headerFooterViewType: GroupHeaderView.self)

Table Header/Footer & Autolayout

Table Header/Footer is for the whole table, not for the sections.

A big problem with header/footer is that it does not support Autolayout nicely (the section header/footer is fine). There are some messy solutions, or trying fanatically setting the header view, layoutIfneeded, etc..

As of iOS 11, I have tested that what is missing is that the header view needs to explicitly call layoutIfneeded.

override func viewDidLoad() {
    super.viewDidLoad()

    // 1. Setup the views ..
    tableView.tableHeaderView = headerView

    // 2. The constraints required (using Cartography)
    constrain(tableView, headerView) { table, header in
        header.centerX == table.centerX
        header.width == table.width
        header.top == table.top
    }
}

// 3. IMPORTANT: This step is required for headerView to resize correctly
override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    headerView.layoutIfNeeded()
}

Nobody really knows the proper way to use table header/footer. Apple published way is using storyboard..

So let me know if this did work for you.

The above solution using auto layout will not work for footer. If you try, the footer will be at the top!

For footer, you can only rely on setting the frame.

The good news is, the width of the footer will be correct, but the height needs to be specified. A fixed height would be simple.

footerView.frame.size = CGSize(width: 0, height: 50)
tableView.tableFooterView = footerView

Image

@samwize

¯\_(ツ)_/¯

Back to Home