Dynamic height cells with a jerky scrolling - ios

I know this is quite a hot topic answered in several places but no answer yet solved my problem. I'm working on an app with a main tableview with multiple cell types (table can be up to hundreds of cells), each one has a different potential height, depends on its content. I'm trying to rely more on dynamic cell heights, calculated by the system when drawing the cell but my scrolling is badly affected when i'm trying to scroll to the bottom of the table.
I understand the estimated height of a cell should be really close to what it is eventually, but there is no way to do that unless I manually calculate the size of each cell by summing up all of its texts, images, constraints and so on... That pretty much knocks the edge out of using dynamic cell heights, doesn't it?
The best solution i've found online is caching up the real cell heights on "cellWillDisplay", but it only works after all cells are presented at least once.
Thing is, when my app loads, it automatically scrolls to bottom without animation so "cellWillDisplay" isn't called for all cells above.
This is my estimatedHeightForRow snippet:
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if let object = self.fetchedResultsController?.object(at: indexPath) as? Object {
if let objectID = object.objectIDPermanentString {
if let savedHeight = self.rowsHeights[objectID] {
return savedHeight
}
}
}
return UITableViewAutomaticDimension
}
This is my cellForRow method:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let object = self.fetchedResultsController?.object(at: indexPath)
let cell = tableView.dequeueReusableCell(withIdentifier: object.id, for: indexPath)
self.configureCell(cell: cell, object: object)
return cell
}

Related

Reusing dynamic TableViewCells without having to maintain of each cell's state in Controller

I have a UITableView that uses a cell that has 3 expandable and collapsable subviews in them. I would prefer to maintain the state of these views in my UITableViewCell class itself (states as in collapsed or expanded)
Since they are reusable cells, currently, if I expand view 1 in cell A, and then scroll down to cell B, it's view 1 will be expanded. I don't want this. I want it collapsed. But, if I scroll back up to cell A, I want it to still be expanded.
Other than storing all of these states in an array or dictionary
var expandedViewOneCells: [Int] = []
var expandedViewTwoCells: [Int] = []
etc.
I would prefer to have the cells essentially of act individually and maintain their own state... But how would I do this when cells are reused? Keep in mind, I will always only have at most 3 of these kinds of cells, so can I set something like only reuse after 3 cells.
Would it be wise to keep an array of the cells I load, and then on cellForRowAt load the cell from that array based on the index and return it?
In your func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell function try not to deque a cell but create a new instance of your cell
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = MyCustomCell()
return cell
}
If you are loading your cell from a xib file you need a way to create your custom cell from that nib. Add the following method to your CustomCell class
static func loadFromNib() -> RequestTableViewCell {
let nib = UINib(nibName: "\(MyCustomCell.self)", bundle: Bundle.main)
let cell = nib.instantiate(withOwner: self, options: nil)[0] as! MyCustomCell
return cell
}
Then in your func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell this will create a new cell for every row and not reuse a cell when scrolling
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = MyCustomCell.loadFromNib()
return cell
}
A solution like this may not be optimal if your table view has a lot of rows but for a SMALL amount of rows this should be okay
I see 2 solutions to your problem:
Use 3 View Controllers. They should never get destroyed, and add / remove the corresponding VC's view on top of the .contentView of the cell as it appears or goes off-screen. See the solution here http://khanlou.com/2015/04/view-controllers-in-cells/ The Custom Cell itself is just a view, shouldn't really be concerned with the state, but if we move that logic to a View Controller - we should be fine, an we are not violating MVC. Plus, the View Controller can keep track of the height of the view, based on the state, and heightForRow(at:) can ask it for that
I'd use a Stack View as this is a perfect scenario for it. I'd probably represent the Cell itself as another stack view. Not sure exactly what the views look like and how they change, but it may end up as simple as hiding / unhiding the second view from the Stack View that represent a "cell".

UITableView jumps and flickers when scrolling

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.

UIStackView in UITableView cell causes jerking when scrolled

I have a UITableView cell with a stack view inside. When the cell is tapped the data source changes and the table view is reloaded. The stack view will now have more views inside and the cell is bigger. However sometimes when I scroll the table there is jerky behaviour. It's almost like the cell size was calculated wrong or something (even though it looks fine). Once the tableview has jerked once it is fine and doesn't do it again until I tap the cell and it adds more stack views.
I am using UITableViewAutomaticDimension on the table view. I have tried removing the cell and the table doesn't jerk. It's defiantly the stack view causing issues.
I set my estimated row height to as close as possible to the calculated height tableView.estimatedRowHeight = 270. No affect. I have also tried implementing the delegate and it makes no difference. I have tried many combinations or sizes and the result is the same. Any idea on what I am doing wrong here? Do stack views in cells just suck?
I think you are on the right track about estimatedRowHeight causing trouble. I encounter this jerking problem and in pretty much every tableView with varying element size. What usually does the job is "caching" cell heights and returning them in delegate, something like:
class MyViewController: UIViewController {
fileprivate var cachedCellHeights = [IndexPath: CGFloat]()
//your code here
}
extension MyViewController: UITableViewDelegate {
public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cachedCellHeights[indexPath] = cell.frame.height
}
public func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if let height = cachedCellHeights[indexPath] {
return height
}
return 270
}
}
It should work as long as you configure your cell (i.e. add new views to stack view) in tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath).
The same applies to section headers.

TableView Cell Dynamic Height

I have a UITableView(Table 1). Inside UITableView Cell I have an another UITableView(Table 2).
In Table 2 the rows's height is UITableViewAutomaticDimension.
My requirement is - I want that Table 2 should not be scroll and takes its full height as per number of rows inside it.So, As per this Table 2 's height I need to set Table 1's row height.
I am using Table 2 's contentSize.height, but its not working. I searched a lot but found nothing.
Code :
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
{
let cell = tableView_First.dequeueReusableCell(withIdentifier: "SecondTableViewCell") as! SecondTableViewCell
return cell.second_TableView.contentSize.height
}
Update:
I have debugged the code and found Table 2's cell's estimated height effects the height of Table 1's cell.
i.e- If estimated height of Table 2's cell is 100 and these are 5 cells. Then this cell.second_TableView.contentSize.height gives me 500. Which is wrong.Because cell has different height 10, 20, 30, 40, 10.
Any suggestions will be appreciated.
Thanks!
May you are getting the wrong result because of below two reasons
When you are getting cell.second_TableView.contentSize.height that time your cell.second_TableView might not be loaded completely thus you are getting wrong height.
You are dequeuing new cell rather getting existing loaded cell.
Please try below code:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
{
//Get existing cell rather deque new one
let cell = tableView_First.cellForRowAtIndexPath(indexPath) as! SecondTableViewCell
//This will force table view to load completely
cell.second_TableView.layoutIfNeeded()
return cell.second_TableView.contentSize.height
}
Hope this will solve your problem.
write this two lines in viewDidLoad,
tableView.estimatedRowHeight = 241
tableView.rowHeight = UITableViewAutomaticDimension
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}

dequeueReusableCell breaks cliptobounds

I've got a very simple UIScrollView with some content (many subviews). This scroll view is used to show some posts made by users (image + text). One of these views is actually the image of the author and it overflows bottom cell bounds. It is thus overlapped with the cell coming after, and using clipToBounds = false I'm able to obtain the desired result. Everything works great if I scroll down. When I start to scroll back up the view that previously was overlying now gets clipped.
Cell overlapping working fine
Cell overlapping not working (when I scroll up)
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = (indexPath.row % 2 == 0) ? "FeedCellLeft" : "FeedCellRight";
let cell = feedScrollView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! FeedCell;
self.setUpCell(cell, atIndexPath: indexPath);
return cell
}
the setUpCell function simply perform some UI related tasks
let row = indexPath.row
cell.postImage.downloadImageFrom(link: rows[row].image, contentMode: .scaleToFill)
cell.postAuthorImage.downloadImageFrom(link: "https://pbs.twimg.com/profile_images/691867591154012160/oaq0n2zy.jpg", contentMode: .scaleToFill)
cell.postAuthorImage.layer.cornerRadius = 22.0;
cell.postAuthorImage.layer.borderColor = UIColor.white.cgColor
cell.postAuthorImage.layer.borderWidth = 2.0;
cell.postAuthorImage.layer.masksToBounds = true;
cell.selectionStyle = .none
cell.postData.layer.cornerRadius = 10.0;
cell.contentView.superview?.clipsToBounds = false;
cell.clipsToBounds = false;
if (indexPath.row % 2 != 0) {
cell.postData.transform = CGAffineTransform.init(rotationAngle: (4 * .pi) / 180);
} else {
cell.postData.transform = CGAffineTransform.init(rotationAngle: (-4 * .pi) / 180);
}
It seems that the deque operation breaks the layout I've made (using autolayout). I've tried many solution like this
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.contentView.superview?.clipsToBounds = false;
cell.clipsToBounds = false;
cell.contentView.clipsToBounds = false;
}
But the results looks always the same. The height of every row is fixed.
I think the issue is with the hierarchy of subviews. When you scroll down, you cells dequeued from top to bottom and added to UITableView in the same order and all looks fine. Because the previous cell is above the following in view hierarchy.
But when you scroll up, cells are dequeued from bottom to top and it means that the cell on top is "behind" the previous cell. You can easily check it with Debugging View Hierarchies feature for Xcode.
You can try to bringSubviewToFront: for example:
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.superview.bringSubview(toFront cell)
}
Updated version
I have made small research in Playgrounds and found only one reasonable option to implement overlapping cells without huge performance issues. The solution is based on cell.layer.zPosition property and works fine (at least in my Playground). I updated the code inside willDisplay cell: with the following one:
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.layer.zPosition = (CGFloat)(tableView.numberOfRows(inSection: 0) - indexPath.row)
}
According to the documentation for .zPosition (Apple Developer Documentation):
The default value of this property is 0. Changing the value of this property changes the the front-to-back ordering of layers onscreen. Higher values place this layer visually closer to the viewer than layers with lower values. This can affect the visibility of layers whose frame rectangles overlap.
So I use current dataSource counter as minuend and indexPath.row of the current cell as subtrahend to calculate zPosition of the layer for each cell.
You can download full version of my playground here.

Resources