I'm experiencing bugs with my application when I'm trying to download and set an image async to a cell with dynamic height.
Video of the bug: https://youtu.be/nyfjCmc0_Yk
I'm clueless: can't understand why it happens. I'm saving the cell heights for preventing jumping issues and stuff, I do even update the height of the cell after setting the image.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
var post : Post
var cell : PostCell
post = Posts.shared.post(indexPath: indexPath)
// I REMOVED SOME NOT IMPORTANT PARTS OF THE CODE
// (like setting the text, etc)
if post.images.count > 0 {
// Images is attached to the post
cell.postImageView.sd_setImage(with: URL(string: appSettings.url + "/resources/img/posts/" + post.images[0]), placeholderImage: nil, options: [.avoidAutoSetImage]) { (image, error, type, url) in
if let error = error {
// placeholder
return
}
DispatchQueue.main.async {
let cgRect = image!.contentClippingRect(maxWidth: 300, maxHeight: 400)
cell.postImageView.isHidden = false
cell.postImageWidthConstraint.constant = cgRect.width
cell.postImageViewHeightConstraint.constant = cgRect.height
cell.postImageView.image = image
cell.layoutIfNeeded()
self.cellHeights[indexPath] = cell.frame.size.height
}
}
} else {
// No image is attached to the post
cell.postImageViewHeightConstraint.constant = 0
cell.postImageWidthConstraint.constant = 0
cell.postImageView.isHidden = true
}
return cell
}
var cellHeights: [IndexPath : CGFloat] = [:]
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cellHeights[indexPath] = cell.frame.size.height
cell.layoutIfNeeded()
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if cellHeights[indexPath] != nil {
return CGFloat(Float(cellHeights[indexPath] ?? 0.0))
}
else {
return UITableViewAutomaticDimension
}
}
I've tried calling tableView.beginUpdates() tableView.endUpdates(), it fixes the problem at the beginning, but when scrolling it creates a weird bug :
https://youtu.be/932Kp0p0gfs
(random appearing small part of the image in the tableview)
And when scrolling up, it jumps to the beginning of the post.. maybe due to the incorrect height value?
What do I do wrong?
You'll surely get such kind of bug if you are to compute the height and the width of the UIImage data and assign those values as the constraint constants of the UIImageView especially in a cell.
Solution for that? Make your datasource/server have a computed width and height and avoid computing it yourself. Meaning in your each Post object, there should be a ready width and height values. That's faster and that solved my issue the same as yours (see this cute personal quick project of mine: https://itunes.apple.com/us/app/catlitter-daily-dose-of-cats/id1366205944?ls=1&mt=8)
Also, I've learned that from some public APIs provided by some established companies like Facebook (each images have its computed sizes ready).
I hope this helps.
Related
I'm setting up a "newsfeed" styled tableview and would like for my custom cells to automatically resize based on two factors:
1) Whether or not the represented post contains an image - If it doesn't, the cell should behave as there though is no ImageView and size appropriately
2) If the post does contain an image, the ImageView should set its height according to the image height (hence, no height constraint on the ImageView) and the cell should resize accordingly
I'm getting extremely erratic behavior from my cells in trying to accomplish this. Initially, the first cell with an image will populate correctly. However, as you scroll through, images begin to size incorrectly and others don't appear at all.
My images are being downloaded asynchronously via Google Firebase.
I'm using Autolayout in my cell and have top, bottom, leading, and trailing constraints set properly for all subviews. The issue seems to be limited only to the ImageView. No other elements are affected.
I've tried setting a height constraint on the ImageView and programmatically resizing it depending on whether or not an image is meant to appear. This does not seem to be helping.
Here are my tableview delegate methods:
// Tableview methods
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return mPosts!.count
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 600
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = newsfeed.dequeueReusableCell(withIdentifier: "newsfeedCell") as! NewsfeedCell
let post = mPosts![indexPath.row]
cell.posterNameDisplay.text = post.getPosterName() + " " + (mAccount?.getFamilyName())!
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMMM dd, yyyy # hh:mm a"
let dateString = dateFormatter.string(from: post.getTimeStamp())
cell.timestampDisplay.text = dateString
cell.postMessageDisplay.text = post.getPostMessage()
AccountUtils.loadProfilePhoto(ProfileId: post.getPosterId(), ProfilePhoto: cell.profilePhoto)
cell.profilePhoto.layer.borderWidth = 1.0
cell.profilePhoto.layer.masksToBounds = false
cell.profilePhoto.layer.borderColor = UIColor.white.cgColor
cell.profilePhoto.layer.cornerRadius = cell.profilePhoto.frame.size.width / 2
cell.profilePhoto.clipsToBounds = true
PostUtils.loadPostImage(View: cell.postImage, PostId: post.getPostId())
return cell
}
And here is the utility method I'm using to download and set the image in the ImageView:
public static func loadPostImage(View postImage: UIImageView, PostId postId: String) {
let storage = Storage.storage()
let storageRef = storage.reference()
let photoRef = storageRef.child("photos/" + Auth.auth().currentUser!.uid + postId + ".jpg")
photoRef.getData(maxSize: 1 * 1024 * 1024) { data, error in
if error != nil {
postImage.image = nil
}
else {
let image = UIImage(data: data!)
postImage.image = image
}
}
}
If your cells have more than one unique layout, each unique layout should receive its own reuse identifier (and better it's own subclass).
Create two cell subclasses - one for text only, the second is for cell with image
Change your func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell to something similar:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let isCellHasImage: Bool = // understand if it will image cell or just text cell
if (isCellHasImage) {
// dequeue and return your image cell
// return cell
}
// dequeue and return your text-only cell
// ...
// return cell
}
I have a UITableView that displays cells with an image and some text. The data is requested on demand - I first ask for data for 10 rows, then for then next 10 and so on. I do this in tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath). The problem is that when I receive the data and need to update the tableview it sometimes jumps and/or flickers. I make a call to reloadData. Here is part of the code:
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
DispatchQueue.global(qos: .background).async {
if indexPath.row + 5 >= self.brands.count && !BrandsManager.pendingBrandsRequest {
BrandsManager.getBrands() { (error, brands) in
self.brands.append(contentsOf: brands as! [Brand])
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.brandsTableView.reloadData()
}
}
}
}
}
}
The height of the cells is constant returned like this:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 70
}
I am using Kingfisher to download and cache the images. Here is some more code from the datasource:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return brands.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifiers.ImageTableCell, for: indexPath) as! ImageTableViewCell
let brand = brands[indexPath.row]
cell.centerLabel.text = brand.brand
cell.leftImageView.image = nil
if let url = BrandsManager.brandLogoURL(forLogoName: brand.logo!) {
let resource = ImageResource(downloadURL: url, cacheKey: url.absoluteString)
cell.leftImageView.kf.setImage(with: resource)
} else {
print("Cannot form url for brand logo")
}
return cell
}
How can I avoid the flickering and jumping of the table view on scroll? I looked at some of the similar questions but couldn't find a working solution for my case.
To remove the jumping issue you need to set estimatedHeightForRowAt the same as your row height. Assuming you will have no performance issues you can simply do the following:
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return self.tableView(tableView, heightForRowAt: indexPath)
}
Or if the cell height is constant you can do tableView.estimatedRowHeight = 70.0.
Why this happens is because table view when reloading will use estimatedRowHeight for the cells that are invisible which results in jumping when the estimated height differs from the actual. To give you an idea:
Let's say that estimated height is 50 while the real height is 75. Now that you have scrolled down so that 10 cells are off the screen you have 10*75 = 750 pixels of content offset. No when reload occurs table view will ignore how many cells are hidden and will try to recompute that. It will keep reusing estimated row height until it finds the index path that should be visible. In this example it starts calling your estimatedHeightForRow with indexes [0, 1, 2... and increasing the offset by 50 until it gets to your content offset which is still 750. So that means it gets to index 750/50 = 15. And this produces a jump from cell 10 to cell 15 on reload.
As for the flickering there are many possibilities. You could avoid reloading the cells that don't need reloading by reloading only the portion of data source that has changed. In your case that means inserting new rows like:
tableView.beginUpdates()
tableView.insertRows(at: myPaths, with: .none)
tableView.endUpdates()
Still it seems strange you even see flickering. If only image flickers then the issue may be elsewhere. Getting an image like this is usually an asynchronous operation, even if the image is already cached. You could avoid it by checking if you really need to update the resource. If your cell is already displaying the image you are trying to show then there is no reason to apply the new resource:
if let url = BrandsManager.brandLogoURL(forLogoName: brand.logo!) {
if url != cell.currentLeftImageURL { // Check if new image needs to be applied
let resource = ImageResource(downloadURL: url, cacheKey: url.absoluteString)
cell.currentLeftImageURL = url // Save the new URL
cell.leftImageView.kf.setImage(with: resource)
}
} else {
print("Cannot form url for brand logo")
}
I would rather put this code into the cell itself though
var leftImageURL: URL {
didSet {
if(oldValue != leftImageURL) {
let resource = ImageResource(downloadURL: url, cacheKey: url.absoluteString)
leftImageView.kf.setImage(with: resource)
}
}
}
but this is completely up to you.
If you are appending data to the end of the tableView, do not call reloadData, which forces recalculation and redraw of all of the cells. Instead use UITableView.insertRows(at:with:) which will perform the appropriate insert animation if you use .automatic and leave the existing cells alone.
How to update tableview cell height after updating image height constraint of image downloaded async?
How to trigger tableView cell relayout after image downloaded and constraints changed?
What's the best method to do this?
Already tried putting the code inside Dispatch main queue, but same bad results. I'm doing this in cellForRow method, also moved it to willDisplayCell. Again and again this problem...
Example of code using Kingfisher library for image caching:
if let imgLink = post.imageLink {
if let url = URL(string: imgLink) {
cell.postImage.kf.setImage(with: url, placeholder: UIImage(), options: nil, progressBlock: nil) { (image, error, cacheType, imageURL) in
if let image = image, cell.tag == indexPath.row {
cell.heightConstraint.constant = image.size.height * cell.frame.size.width / image.size.width
}
}
}
}
You may try to call these 2 lines to cause cell heights be recalculated after an image becomes available:
tableView.beginUpdates()
tableView.endUpdates()
See in the documentation https://developer.apple.com/documentation/uikit/uitableview/1614908-beginupdates: "You can also use this method followed by the endUpdates() method to animate the change in the row heights without reloading the cell."
IMHO, As ppalancica pointed out calling beginUpdates and endUpdates is the ideal way. You can't refer tableView from inside UITableViewCell and the proper way is to use a delegate and call beginUpdates and endUpdates from ViewController implementing delegate.
Delegate:
protocol ImageCellDelegate {
func onLayoutChangeNeeded()
}
UITableViewCell implementation:
class ImageCell: UITableViewCell {
var imageView: UIImageView = ...
var delegate: ImageCellDelegate?
...
func setImage(image: UIImage) {
imageView.image = image
//calling delegate implemented in 'ViewController'
delegate?.onLayoutChangeNeeded()
}
...
}
ViewController Implementation:
class ViewController: UIViewController, UITableViewDataSource, ImageCellDelegate {
var tableView: UITableView = ...
.....
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let imageCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! ImageCell
//setting 'delegate' here
imageCell.delegate = self
return imageCell
}
//called from 'ImageCell' when 'image' is set inside 'setImage'
func onLayoutChangeNeeded() {
tableView.beginUpdates()
tableView.endUpdates()
}
.....
}
I had the same problem. What you need to remember is Tableview reuse the cell and you are loading image async.
Recommended: You can do is to request your backhand team to provide you height and width of image so you can calculate cell height and return asap.
If you can't do that you can keep size of dowloaded image in your datasource. so before you download image check your datasource for size of image and update height constraint constant.
Another thing is you should do it in both cellForRow and willDisplay cell (I know it is not good practice but to satisfy tableview automatic dimension)
after update height constant you should use this pair of code to reload your cell.
cell.setNeedsLayout()
cell.layoutIfNeeded()
How I did
if let imagesize = post.imageSize {
cell.updateHeightPerRatio(with: imagesize)
} else {
// Here load size from URL and update your datasource
}
// Load image from URL with any SDWebimage or any other library you used
What I actually did and worked somehow is the following:
if (self.firstLoaded[indexPath.row] == false) {
self.firstLoaded[indexPath.row] = true
DispatchQueue.main.async {
self.tableViewController.tableView.reloadData()
}
}
firstLoaded just tells the table that this row has already received image from URL and calculated / stored correct height.
Also, I used this:
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cellHeights[indexPath] = cell.frame.size.height
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if let height = cellHeights[indexPath] {
return height
}
return 1500
}
I know that calling reloadData() is not a good practice, but it solved my problem. If anybody has some other advices, please do not hesitate to share it.
Thanks!
I have a table view cell. I make an app for a tenant in the apartment to report the defect of the room facility. if the defect has been repaired (status: Complete), data from server will give defect.status == 2 (if defect.status == 1, still on process to be repaired), and it will show YES and NO Button like the picture above.
I want if it still on the repairment process, the view that contains "Are You satisfied" label and Yes No Button will not appear. The expected result should be like the picture below
here is the code I use to remove that satisfied or not view
extension RequestDefectVC : UITableViewDataSource {
//MARK: Table View Delegate & Datasource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return listDefects.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "defectCell", for: indexPath) as! RequestDefectCell
let dataDefect = listDefects[indexPath.row]
cell.defectData = dataDefect
if dataDefect.status == 2 {
if let wantToRemoveView = cell.commentResponseView {
wantToRemoveView.removeFromSuperview()
}
}
return cell
}
}
but unfortunately, if that wantToRemoveView.removeFromSuperview() is triggered, it will remove all the view in all cell, even though the status is completed like picture below
I want that satisfied or not view appears if the status is complete, otherwise, it will be removed. how to do that ?
For your costumed cells are reused, removing views will cause uncertain effects. You don't actually need the specific view to be removed, only if it stays invisible.
if dataDefect.status == 2 {
if let wantToRemoveView = cell.commentResponseView {
wantToRemoveView.isHidden = true
}
} else {
if let wantToRemoveView = cell.commentResponseView {
wantToRemoveView.isHidden = false
}
}
Create a height constraint for that view and hook it as IBOutlet and control it's constant according to that in cellForRowAt
self.askViewH.constant = show ? 50 : 0
cell.layoutIfNeeded()
return cell
I expect you using automatic tableView cells
#Alexa289 One suggestion is that you can take heightConstraints of UIView. then create IBOutlet of your height constraints and make its constant 0 when you want to hide otherwise assign value to your static height.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "defectCell", for: indexPath) as! RequestDefectCell
let dataDefect = listDefects[indexPath.row]
cell.defectData = dataDefect
if dataDefect.status == 2 {
cell.viewSatisficationHeightConstraints.constant = 50
} else {
cell.viewSatisficationHeightConstraints.constant = 0
}
return cell
}
Second suggestion is that you can take label and button in view and embed stackview to your view(view contain label and button)
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "defectCell", for: indexPath) as! RequestDefectCell
let dataDefect = listDefects[indexPath.row]
cell.defectData = dataDefect
if dataDefect.status == 2 {
cell.viewSatisfication.isHidden = false
} else {
cell.viewSatisfication.isHidden = true
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 40
}
you can read about UIStackView which makes hiding things easier. If you are not using stackview and hiding things the UI will not good as the space used by the hidden view will be still there. So better to use stackView when need to hide or show some view.
UIStackView : https://developer.apple.com/documentation/uikit/uistackview
I am trying to make dynamic image height in tableview. i used SDWebImage to download image from URL. but it's not working on few initial cell
Here below methods that i wrote
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let shareDetailCell = tableView.dequeueReusableCell(withIdentifier: THShareDetailTableViewCell.className, for: indexPath) as! THShareDetailTableViewCell
shareDetailCell.selectionStyle = UITableViewCellSelectionStyle.none
shareDetailCell.likeButton.tag = indexPath.row
//to set image dynamic height
shareDetailCell.selectionStyle.socialPostImage.sd_setImage(with: URL(string: socialInteraction[indexpath.row].largeImageUrl), placeholderImage: nil, options: .retryFailed) { (image, error, imageCacheType, imageUrl) in
if image != nil {
let imgHeight = (image?.size.height)!
shareDetailCell.heightConstraintSocialImage.constant = (imgHeight * (self.socialPostImage.frame.size.width/imgHeight))
}else {
print("image not found")
}
}
return shareDetailCell
}
From your code work I found two things ,
Never return UITableViewAutomaticDimension from estimatedHeightForRowAt because estimated height is never be Automatic otherwise x-code does not understand what height it should need to return. Sometimes it works but not considered as a good practice.
You fetching the Images in the cellForRowAt method that means upto you fetch the image the height of cell is already set. So your cell images that height only.UITableViewAutomaticDimension works when system knows the height of that cell at the heightForRowAt method not after that.
Suggestion for your problem.
Once you fetch the data and then reload your tableView so that cell height is adjusted according to the image height. You can do this in paging also.