Reputation: 327
Summary My UITableView with dynamic cell heights randomly scrolls when inserting new cells at the top.
Detailed Explanation As part of the UITableViewController, to avoid inaccurate scrolling when I trigger reloadData() and scroll I have the following working implementation based on this stack overflow answer:
var cellHeightsDictionary: [IndexPath: CGFloat] = [:]
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cellHeightsDictionary[indexPath] = cell.frame.size.height
}
override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if let height = cellHeightsDictionary[indexPath] {
return height
}
return UITableView.automaticDimension
}
This works very well except when adding new data at the top in the data source and triggering reloadData(). When doing that, I've not found a proper implementation that prevents the table from randomly scrolling... This is the problem I'm trying to solve.
What I've tried
I tried using the difference between contentSize before and after the update, and then updating the scrolling position. Unfortunately, it seems -based on what I've read in the Apple docs- that when using Dynamic cells the contentSize can't be trusted for this purpose and was not working for me. Nontheless for reference, here's one of my implementations.
UIView.performWithoutAnimation {
//Detect exact position of the cell in relation to navBar
let navBar = navigationController?.navigationBar
let cellRectInTable = tableView.rectForRow(at: IndexPath(row: topArticle + countOfAddedItems, section: 0))
let positionVsNavBar = tableView.convert(cellRectInTable.origin, to: navBar)
let positionVsTop = tableView.convert(cellRectInTable.origin, to: tableView.superview)
let offsetOfCell = positionVsTop.y - positionVsNavBar.y
tableView.reloadData()
tableView.scrollToRow(at: IndexPath(row: topArticle + countOfAddedItems, section: 0), at: .top, animated: false)
tableView.contentOffset.y = tableView.contentOffset.y + offsetOfCell
}
I tried setting tableView.estimatedRowHeight = 0, tableView.estimatedSectionHeaderHeight = 0 and tableView.estimatedSectionFooterHeight = 0 before the addition, but it seemed to have no impact whatsoever.
The approach that seemed to work best was when I was getting the heights of cells from the cellHeightsDictionary and sum them based on the number of additions. This worked sometimes flawlessly but sometimes not. Also, when adding a lot of rows it may crash as it was not finding a value in cellHeightsDictionary. I tried multiple variations of this but did not get it to work. Equally important, I'm not sure exactly why this gave me best results so I may be missing something obvious. I have a feeling somewhere in this last implementation is the answer but I've been unable to find it yet.
var iteratorCounter = 0
var heightCum = CGFloat(integerLiteral: 0)
while iteratorCounter < countOfAddedItems {
heightCum = heightCum + cellHeightsDictionary[IndexPath(row: iteratorCounter, section: 0)]!
iteratorCounter = iteratorCounter + 1
}
UIView.performWithoutAnimation {
tableView.reloadData()
tableView.layoutIfNeeded()
tableView.contentOffset.y = tableView.contentOffset.y + heightCum
}
I've tried everything I can think of and searched everywhere I thought of with no luck. Any help appreciated
Thanks a million!
Upvotes: 1
Views: 842
Reputation: 327
The approach that ended up working smoothly was:
Step 1. Storing in a variable the UITableViewCell below the Navigation Bar and storing the offset of that cell in relation to the Navigation Bar.
Step 2. Insert cells / reloadData.
Step 3.Scroll to the cell we saved before the insert/reload and then add the offset.
Here’s the code:
UIView.performWithoutAnimation {
//Step 1 Detect & Store
let topWillBeAt = getTopVisibleRow() + countOfAddedItems
let oldHeightDifferenceBetweenTopRowAndNavBar = heightDifferenceBetweenTopRowAndNavBar()
//Step 2 Insert
self.tableView.insertRows(at: arrayOfIndexPaths, with: .none)
//Step 3 Restore Scrolling
tableView.scrollToRow(at: IndexPath(row: topWillBeAt, section: 0), at: .top, animated: false)
tableView.contentOffset.y = tableView.contentOffset.y - oldHeightDifferenceBetweenTopRowAndNavBar
}
And supporting functions:
func getTopVisibleRow () -> Int {
//We need this to accounts for the translucency below the nav bar
let navBar = navigationController?.navigationBar
let whereIsNavBarInTableView = tableView.convert(navBar!.bounds, from: navBar)
let pointWhereNavBarEnds = CGPoint(x: 0, y: whereIsNavBarInTableView.origin.y + whereIsNavBarInTableView.size.height + 1)
let accurateIndexPath = tableView.indexPathForRow(at: pointWhereNavBarEnds)
return accurateIndexPath?.row ?? 0
}
func heightDifferenceBetweenTopRowAndNavBar()-> CGFloat{
let rectForTopRow = tableView.rectForRow(at:IndexPath(row: getTopVisibleRow(), section: 0))
let navBar = navigationController?.navigationBar
let whereIsNavBarInTableView = tableView.convert(navBar!.bounds, from: navBar)
let pointWhereNavBarEnds = CGPoint(x: 0, y: whereIsNavBarInTableView.origin.y + whereIsNavBarInTableView.size.height)
let differenceBetweenTopRowAndNavBar = rectForTopRow.origin.y - pointWhereNavBarEnds.y
return differenceBetweenTopRowAndNavBar
}
Upvotes: 0
Reputation: 2470
Define a variable :
var itemHeights = [CGFloat](repeating: UITableViewAutomaticDimension, count: 1000)
count can be changed according your requirement
and update tableView's methods
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if itemHeights[indexPath.row] == UITableViewAutomaticDimension {
itemHeights[indexPath.row] = cell.bounds.height
}
// fetch more cells, etc.
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return itemHeights[indexPath.row]
}
Upvotes: 0
Reputation: 4282
As a last resort, you could embed the table view in a new scrollview and turn off the table view's scrolling. You can fully control the scrolling behavior (albeit all programmatically).
In this case, we have to add a layout constraint corresponds to the height of the table view, and an outlet for this constraint.
If our cell view has appropriate layout, we could calculate the exact cell height with systemLayoutSizeFittingSize
method. If the cell has multiple labels (e.g. dynamic contents), try calculating with fixed width (add an explicit width constraint in the cell).
Having this, we can reload the table view like below:
1) insert data into the data source
2) recalculate content height
3) modify the height constraint constant
4) call layoutIfNeeded
5) reload table view
6) move scrollview content offset appropriately => setContentOffset(_ contentOffset: CGPoint, animated: Bool)
Upvotes: 1