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:
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:
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 uniquecellIdentifier
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
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 }
}
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)