NSTableView unreliable programmatical scrolling

I've been fighting on this one for over two weeks now: Scrolling after tableView.reloadData() does not work for me reliably. Here's what I'm doing (code follows).


1. I have an array of RSS objects fetched from the web, once fetched these object get loaded in `feedManager.arrayOfFeedItems`.


2. When it's time to refresh the feeds the app will:


a) MODEL: Copy `feedManager.arrayOfFeedItems` into `feedManager.previousArrayOfFeedItems`, this is done so when is time to reload the table I can know what article was being read by the user at the top of the app.
It will then fetch new articles and add them, if appropriate, to `feedManager.arrayOfFeedItems`. Once all articles have been fetched it will tell the TVC to reload the table.


b) TVC: It begins `tableView.beginUpdates()`, grabs the article we should go to and stores it into `articleToGoTo` by using `feedManager.previousArrayOfFeedItems[tableView.rows(in: tableView.visibleRect).lowerBound]` and then reloads the data using `tableView.reloadData()`. Using the stored article we will get its position in the updated array using: `let rowToGoTo = feedManager.arrayOfFeedItems.index(of: articleToGoTo!)!`. At that point we will scroll to that row by calling 'tableView.scrollRowToVisible(rowToGoTo)' and we will endUpdates `tableView.endUpdates()`.


EXPECTATION: The app reloads the data and goes to the right cell.


REALITY: Ignores me completely, IF I add a timer that then executes the `scrollCellToVisible(row: row)` like so `Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(test) , userInfo: nil, repeats: false)`, THEN it works.


What am I missing? Why can't I run it without a timer?



func updateTableView(){

        tableView.beginUpdates()
    
        var articleToGoTo : FeedItemComponents? = nil
     
        if feedManager.previousArrayOfFeedItems.count > 0 {
            articleToGoTo = feedManager.previousArrayOfFeedItems[tableView.rows(in: tableView.visibleRect).lowerBound]
        }
    
        tableView.reloadData()
    
  
        if articleToGoTo != nil {
            let rowToGoTo = feedManager.arrayOfFeedItems.index(of: articleToGoTo!)!
            print("Scrolling to: \(articleToGoTo!.title), at index: \(rowToGoTo)")
            scrollCellToVisible(row: rowToGoTo)
        }
     
        tableView.endUpdates()
        print("UPDATE TABLE VIEW - DONE")
    }


For reference, scrollCellToVisible is a function that scrolls to the top, I don't think there's an issue with it but posting it here just in case:


   @objc func scrollCellToVisible(row: Int) {

        let rowRect = tableView.rect(ofRow: row)
        let scrollOrigin = rowRect.origin
        let clipView = tableView.superview as? NSClipView
        clipView!.animator().setBoundsOrigin(scrollOrigin)
     


Some extra info:

The number of cells and their height changes depending on the information fetched.

I'm using the newly introduced: Table View Row Size Stype: Automatic(Auto Layout).


Thanks a million in advance for any tips that help me solve this.

Accepted Reply

a. In retrospect, the idea that you wouldn't need to scroll the current article back into view afterwards seems … implausible. Sorry about that. OTOH, I think that keeping the current article in the table (while you animate insertions) is a good UI choice.


Your code for inserting rows isn't quite right. In a table view, there are always two halves to this sort of operation: the data model update, and the table structure update. At the moment when you invoke the insertRows(at:) method, the table view needs the data model (as accessed via the data source) to match the structure that the insertion implies.


You aren't doing this. You're saying that the table has an extra row (inserted at 0), but your data (previousArrayOfFeedItems or arrayOfFeedItems? it's not clear which one your data source actually uses) doesn't necessarily match that. You're going to need to resolve the uncertainty about which data the table is using, and make sure that when you insert or delete, the data source reports matching data. Specifically, if you insert or delete in a loop, you have to update the data source in the same loop. (In your case, though, if you just have insertions at the top, you can avoid the loop, and do everything in a single step.)


I hope that makes sense. It's a bit hard to explain clearly without over-explaining.


b. It may be that you need to do this scrolling manually, and maybe after a delay, but it's not clear yet whether the code isn't working right or you're seeing the effects of part (a). One question to keep in mind is what the table view regards as "visible" for a row. If even a tiny piece of the row is visible already, it may not scroll at all in response to this method. You might ultimately need to scroll via NSClipView's scrollToPoint method if you always want to ensure that the current article is in exactly the same place as before. Doing the coordinate system match for that is a PITA, though.


If you're fluent enough in Obj-C, I also suggest to study the TableViewPlayground sample app for clues how to do things. For example, I found this fragment of code in developer.apple.com/library/content/samplecode/TableViewPlayground/Listings/TableViewPlayground_ATComplexTableViewController_m.html, in the "btnInsertNewRow" method:


    [self.tableContents insertObject:entity atIndex:index];
    [self.tableViewMain beginUpdates];
    [self.tableViewMain insertRowsAtIndexes:[NSIndexSet indexSetWithIndex:index] withAnimation:NSTableViewAnimationEffectFade];
    [self.tableViewMain scrollRowToVisible:index];
    [self.tableViewMain endUpdates];


Note that this updates the data model with an insertion (line 1), then informs the table view (line 3), as I was discussing earlier. It also does a scroll right away, suggesting that this is supposed to work. You might want to try running this sample app to see if its behavior matches what you want, and then you should be able to copy its techniques. It is, unfortunately, a pretty big sample, and something of a maze to find which code does what in the UI.

Replies

There are several things wrong here.


— You cannot usefully call "reloadData" inside a "beginUpdates"/"endUpdates" pair. The only purpose of begin/endUpdates to collect a set of insertions, removals and moves in order to have them share a single animation timing. Not only are you not doing any insertions, removals or moves, a call to "reloadData" causes the table to discard everything it knows and start over from scratch, which invalidates the update you're in the middle of.


— The reason it behaves like it does is that (AFAIK) the effect of reloadData is deferred until the next iteration of the main thread run loop. Scrolling after a timer defers the scroll until after that, so it does what you expect.


— You say initially that you use 'scrollRowToVisible', which might conceivably be expected to do the right thing if issued immediately after a reloadData or endUpdates, but your code uses a "scrollCellToVisible" hack which is not obviously correct, given that NSTableView does some things asynchronously.


When you get a complete new set of objects, you should either replace your model array and call reloadData immediately after (which means there's no animation of the change) or issue a set of insertion and removal calls inside a begin/endUpdates pair without any explicit reload. In general, table views behave more gracefully if you issue changes rather than brute-force-reload them.


If you're trying to preserve the visibility of the article being read and trying to animate the changes, you're going to get really bad results if this involves deleting and re-inserting that article. What I would suggest is to use begin/endUpdates, remove only the articles before and after the one being read, insert new articles before and after the one being read, and then you probably won't need to scroll anything. (Alternatively, remove only the articles that are not in the new list, insert only the articles that are new in the new list, and move articles that are relocated in the new list, leaving the article being read alone.)


If that's not feasible, you can try using 'scrollRowToVisible' (after endUpdates and without any reload) to see if it defers itself properly, otherwise you might have to go on using a short timer to delay the requisite scrolling.

Thanks for taking the time to answer, will recode to use your suggested approach.

I'Hi Quincey,


I tried your approach and I'm affraid I'm still getting the same problem.

a) I've tried to find a way so that insertRows appear above the top, but have found none. Maybe I'm missing something.

b) I've tried to scroll with delay after the cells have been added, also no luck. Randomly it will work but it's inconsistent.


         var articleCell : FeedItemComponents? = nil
      
        if feedManager.previousArrayOfFeedItems.count > 0 {
            articleCell = feedManager.previousArrayOfFeedItems[tableView.rows(in: tableView.visibleRect).lowerBound]
        }else{
            return
        }
     
        tableView.beginUpdates()
        var articleDifference = feedManager.arrayOfFeedItems.count - feedManager.previousArrayOfFeedItems.count
        while articleDifference > 0 {
            print("Adding article: \(articleDifference)")
            tableView.insertRows(at: IndexSet(integer: 0), withAnimation: .effectGap)
            articleDifference = articleDifference - 1
        }
     
        tableView.endUpdates()
     
        cellToScrollToUsedInUpdateTableView = feedManager.arrayOfFeedItems.index(of: articleCell!)!
        print("Scrolling to: \(articleCell!.title) at index \(cellToScrollToUsedInUpdateTableView)")
        Timer.scheduledTimer(timeInterval: 0.3, target: self, selector: #selector(scrollWithDelayToBeCalledViaSelector) , userInfo: nil, repeats: false)
    


In scrollWithDelayToBeCalledViaSelector I'm using:


  @objc func scrollWithDelayToBeCalledViaSelector (){
        tableView.scrollRowToVisible(cellToScrollToUsedInUpdateTableView)
    }


Most solution I'm finding online are for iOS and do not translate well to OSX.


Thanks a million,


Marc

a. In retrospect, the idea that you wouldn't need to scroll the current article back into view afterwards seems … implausible. Sorry about that. OTOH, I think that keeping the current article in the table (while you animate insertions) is a good UI choice.


Your code for inserting rows isn't quite right. In a table view, there are always two halves to this sort of operation: the data model update, and the table structure update. At the moment when you invoke the insertRows(at:) method, the table view needs the data model (as accessed via the data source) to match the structure that the insertion implies.


You aren't doing this. You're saying that the table has an extra row (inserted at 0), but your data (previousArrayOfFeedItems or arrayOfFeedItems? it's not clear which one your data source actually uses) doesn't necessarily match that. You're going to need to resolve the uncertainty about which data the table is using, and make sure that when you insert or delete, the data source reports matching data. Specifically, if you insert or delete in a loop, you have to update the data source in the same loop. (In your case, though, if you just have insertions at the top, you can avoid the loop, and do everything in a single step.)


I hope that makes sense. It's a bit hard to explain clearly without over-explaining.


b. It may be that you need to do this scrolling manually, and maybe after a delay, but it's not clear yet whether the code isn't working right or you're seeing the effects of part (a). One question to keep in mind is what the table view regards as "visible" for a row. If even a tiny piece of the row is visible already, it may not scroll at all in response to this method. You might ultimately need to scroll via NSClipView's scrollToPoint method if you always want to ensure that the current article is in exactly the same place as before. Doing the coordinate system match for that is a PITA, though.


If you're fluent enough in Obj-C, I also suggest to study the TableViewPlayground sample app for clues how to do things. For example, I found this fragment of code in developer.apple.com/library/content/samplecode/TableViewPlayground/Listings/TableViewPlayground_ATComplexTableViewController_m.html, in the "btnInsertNewRow" method:


    [self.tableContents insertObject:entity atIndex:index];
    [self.tableViewMain beginUpdates];
    [self.tableViewMain insertRowsAtIndexes:[NSIndexSet indexSetWithIndex:index] withAnimation:NSTableViewAnimationEffectFade];
    [self.tableViewMain scrollRowToVisible:index];
    [self.tableViewMain endUpdates];


Note that this updates the data model with an insertion (line 1), then informs the table view (line 3), as I was discussing earlier. It also does a scroll right away, suggesting that this is supposed to work. You might want to try running this sample app to see if its behavior matches what you want, and then you should be able to copy its techniques. It is, unfortunately, a pretty big sample, and something of a maze to find which code does what in the UI.