TableView w. Dynamic Height scrolls when reloading with new data

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


1. 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 {
  if debugMode == true{
  print("updateTableViewAfterAddition: Adding and scrolling with no animation.")
  }
  //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

  //Reload data
  tableView.reloadData()
  //Navigate to row
  tableView.scrollToRow(at: IndexPath(row: topArticle + countOfAddedItems, section: 0), at: .top, animated: false)
  //Add offset
  tableView.contentOffset.y = tableView.contentOffset.y + offsetOfCell
}


2. I tried setting tableView.estimatedRowHeight = 0, tableView.estimatedSectionHeaderHeight = 0 and tableView.estimatedSectionFooterHeight = 0 before the addition, but it seemed to have no impact whatsoever.


3. 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,


Marc

Accepted Reply

I've been able to fix it and thought to post it here for future readers:


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
            lettopWillBeAt = getTopVisibleRow() + countOfAddedItems
            letoldHeightDifferenceBetweenTopRowAndNavBar = 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:

funcgetTopVisibleRow () -> Int{
        letnavBar = navigationController?.navigationBar
        letwhereIsNavBarInTableView = tableView.convert(navBar!.bounds, from: navBar)
        letpointWhereNavBarEnds = CGPoint(x: 0, y: whereIsNavBarInTableView.origin.y+ whereIsNavBarInTableView.size.height+ 1)
        letaccurateIndexPath = tableView.indexPathForRow(at: pointWhereNavBarEnds)
        returnaccurateIndexPath?.row?? 0
    }
   
    funcheightDifferenceBetweenTopRowAndNavBar()-> CGFloat{
        letrectForTopRow = tableView.rectForRow(at:IndexPath(row:  getTopVisibleRow(), section: 0))
        letnavBar = navigationController?.navigationBar
        letwhereIsNavBarInTableView = tableView.convert(navBar!.bounds, from: navBar)
        letpointWhereNavBarEnds = CGPoint(x: 0, y: whereIsNavBarInTableView.origin.y+ whereIsNavBarInTableView.size.height)
        letdifferenceBetweenTopRowAndNavBar = rectForTopRow.origin.y- pointWhereNavBarEnds.y
        returndifferenceBetweenTopRowAndNavBar

    }

Replies

I've been told that the issue may be with my use of reloadData() and that I should be using insertRows instead. I've tried that -implementation below-, and it randomly works. I'm unable to find any pattern and its really driving me mad.


        var arrayOfIndexPaths = [IndexPath]()
        var countOfCellsToAdd = countOfAddedItems
        while countOfCellsToAdd > 0 {
            countOfCellsToAdd = countOfCellsToAdd - 1
            arrayOfIndexPaths.append(IndexPath(row: countOfCellsToAdd, section: 0))
        }

        UIView.performWithoutAnimation {
            self.tableView.estimatedRowHeight = UITableView.automaticDimension

            self.tableView.insertRows(at: arrayOfIndexPaths, with: .none)
            self.tableView.layoutIfNeeded()

            var calculatedDifference: CGFloat = 0
            for iterator in arrayOfIndexPaths{
                if cellHeightsDictionary[iterator] != nil{
                    calculatedDifference = calculatedDifference + cellHeightsDictionary[iterator]!
                    print("Adding \(cellHeightsDictionary[iterator]!)")
                }else{
                    print("Adding height of UITableView.automaticDimension")
                    calculatedDifference = calculatedDifference + UITableView.automaticDimension
                }
            }
            self.tableView.contentOffset.y = self.tableView.contentOffset.y + calculatedDifference
        }
       


Any help appreciated, really struggling here. Thanks and happy holidays 🙂


Marc

I've been able to fix it and thought to post it here for future readers:


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
            lettopWillBeAt = getTopVisibleRow() + countOfAddedItems
            letoldHeightDifferenceBetweenTopRowAndNavBar = 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:

funcgetTopVisibleRow () -> Int{
        letnavBar = navigationController?.navigationBar
        letwhereIsNavBarInTableView = tableView.convert(navBar!.bounds, from: navBar)
        letpointWhereNavBarEnds = CGPoint(x: 0, y: whereIsNavBarInTableView.origin.y+ whereIsNavBarInTableView.size.height+ 1)
        letaccurateIndexPath = tableView.indexPathForRow(at: pointWhereNavBarEnds)
        returnaccurateIndexPath?.row?? 0
    }
   
    funcheightDifferenceBetweenTopRowAndNavBar()-> CGFloat{
        letrectForTopRow = tableView.rectForRow(at:IndexPath(row:  getTopVisibleRow(), section: 0))
        letnavBar = navigationController?.navigationBar
        letwhereIsNavBarInTableView = tableView.convert(navBar!.bounds, from: navBar)
        letpointWhereNavBarEnds = CGPoint(x: 0, y: whereIsNavBarInTableView.origin.y+ whereIsNavBarInTableView.size.height)
        letdifferenceBetweenTopRowAndNavBar = rectForTopRow.origin.y- pointWhereNavBarEnds.y
        returndifferenceBetweenTopRowAndNavBar

    }