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.
Related
I have a tableView and cells. The Cells are loaded from a xib and they have a label with automatic height. I need to narrow one cell if the user taps on it.
I have tried hiding - doesn't work
I have tried removeFromSuperView()- doesn't work
Is there any alternative?
When setting up your tableViewCell store the height anchor you want to update
var yourLabelHeightAnchor: NSLayoutConstraint?
private func setupLayout() {
yourLabelHeightAnchor = yourLabel.heightAnchor.constraint(equalToConstant: 50)
// Deactivate your height anchor as you want first the content to determine the height
yourLabelHeightAnchor?.isActive = false
}
When the user clicks on a cell, notify the tableView that the cell is going to change, and activate the height anchor of your cell.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let cell = tableView.dequeueReusableCell(withIdentifier: "YourTableViewCellIdentifier") as? YourCell
self.tableView.beginUpdates()
cell?.yourLabelHeightAnchor?.isActive = true
self.tableView.endUpdates()
}
Did you try to do something like this:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
var result: CGFloat
if (indexPath.row==0) {
result = 50 }
else {result = 130}
return result
}
This is just an example where height is changed for the first row. I tested on my application and it gave result like this.
I am trying to add some rows in my table view. when inserting rows are above the rows which are on the screen, the table view jumps up. I want my table view to stay in the position it is already in when I insert rows above. Keep in mind: tableView jump to indexPath that it was showing but after adding rows above, bottom rows indexPaths changes and the new n'th indexPath is something else.
This is unfortunately not as easy task as one would think. Table view jumps when you add a cell on top because the offset is persisted and cells updated. So in a sense it is not the table view that jumps, cells jump since you added a new one on top which makes sense. What you want to do is for your table view to jump with the added cell.
I hope you have fixed or computed row heights because with automatic dimensions things can complicate quite a bit. It is important to have the same estimated height as actual height for row. In my case I just used:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 72.0
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 72.0
}
Then for testing purposes I add a new cell on top whenever any of the cells is pressed:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
var offset = tableView.contentOffset.y
cellCount += 1
tableView.reloadData()
let paths = [IndexPath(row: 0, section: 0)]
paths.forEach { path in
offset += self.tableView(tableView, heightForRowAt: path)
}
DispatchQueue.main.async {
tableView.setContentOffset(CGPoint(x: 0.0, y: offset), animated: false)
}
}
So I save what the current offset of the table view is. Then I modify the data source (My data source is just showing number of cells). Then simply reload the table view.
I grab all the index paths that have been added and I modify the offset by adding the expected height of every added cell.
At the end I apply the new content offset. And it is important to do that in the next run loop which is easies done by dispatching it asynchronously on main queue.
As for automatic dimensions.
I would not go there but it should be important to have size cache.
private var sizeCache: [IndexPath: CGFloat] = [IndexPath: CGFloat]()
Then you need to fill the size cache when cell disappears:
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
sizeCache[indexPath] = cell.frame.size.height
}
And change the estimated height:
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return sizeCache[indexPath] ?? 50.0
}
Also when modifying your offset you need to use estimated height:
paths.forEach { path in
offset += self.tableView(tableView, estimatedHeightForRowAt: path)
}
This worked for my case but automatic dimensions are sometimes tricky so good luck with them.
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.
A similar question is this, except I don't have estimated row heights, I store the actual heights into a dictionary, and either use those, or use UITableViewAutomaticDimension.
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cellHeightDict[indexPath.row] = cell.frame.size.height
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if let height = cellHeightDict[indexPath.row] {
return height
} else {
return UITableViewAutomaticDimension
}
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let height = cellHeightDict[indexPath.row] {
return height
} else {
return UITableViewAutomaticDimension
}
}
So I'm making a messaging app in Swift 4 right now. When the user shows the keyboard, I want the messages to shift up with the keyboard. So in order to do this, I have a variable keyboardHeight which correctly gets the height of the keyboard:
let keyboardHeight: CGFloat = ((userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height)!
I change the height of the table view by keyboardHeight:
self.messageTableViewHeight.constant -= keyboardHeight
So the users can see all of the messages. And to shift up the messages with the keyboard animation, I changed the contentOffset of the table view:
self.messageTableView.contentOffset.y += keyboardHeight
All of this is in a UIView.animationWithDuration, and I call self.view.layoutIfNeeded() after, so the shifting and everything works fine. But, when I send a message without scrolling to the bottom, the messageTableView content jumps down??? And it seems to shift down by keyboardHeight, at least when I eye it. I am using messageTableView.reloadData(). Here are some images.
Before message sent
It jumps up in between the "Hi" and "Testing" messages.
After message sent
As soon as you reload the data of the tableview messageTableView.reloadData(). Every row is reloaded but it is not able to calculate the actual content size somehow. You can try reloading the data in the main queue.
DispatchQueue.main.async {
messageTableView.reloadData()
}
If it still doesn't works, you can add one more thing to the above code. Suppose you have an array of messages in your class. var messages then you can use this code to display data correctly in the tableview.
DispatchQueue.main.async {
messageTableView.reloadData()
if messages.count > 0 {
let indexPath = IndexPath(row: messages.count - 1, section: 0)
self.messageTableView.scrollToItem(at: indexPath, at: .bottom, animated: true)
}
}
Hope this helps. Happy coding.
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.