NSTableView hideRows not behaving as documented

Hi there


I am trying to hide rows in my NSOutlineView.

The initial call to hideRows(at:withAnimation) gives the correct results.

However, when ever I drag a row my hidden rows are cancelled and shown again.


The documentation says that hideRows(at:withAnimation) will result in a call to tableView(_:didRemove:forRow:), and that unhideRows(at:withAnimation) will result in a call to tableView(_:didAdd:forRow:). However, this is not what I am seeing.


hideRows(at:withAnimation) results in a call to didRemove AND didAdd imediately after. The hiddenRowIndexes property remains correct at this time. When unhideRows is called there is no delegate call to didAdd, although the row is resized correctly, and a call to heightOfRowByItem is made. hiddenRowIndexes is correctly reset.


When starting to drag, the hiddenRowIndexes property is silently updated and the rows all shown again. heightOfRowByItem is called for all the rows being reshown.


So, is there a way to stop drag cancelling my hidden rows?

If not, is there a way to work around this?


I cannot call hideRows again in the only consistent call I get - in heightOfRowByItem.

I can call hideRows again in draggingSession:willBeginAt which keeps rows hidden during the drag.

However, the rows are unhidden again at the end of the drag session.

Calling hideRows again in dragginSession:didEndAt does not work (maybe it's too early).

Calling hideRows again in heightOfRowByItem causes a crash.

It is not possible to return 0 in heightOfRowByItem.

I can see no event I can catch to immedeately rehide my rows.

(It is not possible observe hiddenRowIndexes on the outline view, or override anything here)


Can anyone help me please?

Good on you for saying what you did, what you expected and how those differed, but it might help to show a few code snippets vs. anecdotal assessments, etc., thanks.

The hiddenRowIndexes property remains correct at this time


How did you check ?

Consider that some actions may occur asynchrounous, so what you test is not what is used when redisplay occurs.

Thank you both for responding to my question.

Here are some further details....


I first noticed the issue in my large project, but was able to reproduce it quickly in someone else's simple example project from GitHub:

https://github.com/thierryH91200/OutlineViewReorder - thank you Thierry!


To repro, I added the following items to the Edit menu:

- Hide row index 2

- Check hidden indexes

- Unhide row index 2


These link to the following methods on the view controller as the first responder:


@IBAction func hideRow(_ sender: AnyObject) {
        print("Hiding row \(hideIndex)")
        theOutline.hideRows(at: [hideIndex], withAnimation: [])
    }
   
    @IBAction func checkHiddenRows(_ sender: AnyObject) {
        print("Hidden row check \(theOutline.hiddenRowIndexes)")
    }
   
    @IBAction func unhideRow(_ sender: AnyObject) {
        print("unHiding row \(hideIndex)")
        theOutline.unhideRows(at: [hideIndex], withAnimation: [])
    }


I updated the outline view delegate to the following:


extension ViewController: NSOutlineViewDelegate {
   
    func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
        let cell = outlineView.make(withIdentifier: "OutlineColItem", owner: self) as! OutlineItemView
       
        cell.textField!.delegate = self
       
        if let folderItem = item as? FolderItem
        {
            cell.textField!.stringValue = folderItem.name
            cell.imageView!.image = folderImage
        }
        else if let aItem = item as? TestItem
        {
            cell.textField!.stringValue = aItem.name
            cell.imageView!.image = itemImage
        }
       
        print("View for item called")
       
        return cell
    }
   
    func outlineView(_ outlineView: NSOutlineView, didAdd rowView: NSTableRowView, forRow row: Int) {
        print("Row added at index: \(row). Hidden row indexs: \(outlineView.hiddenRowIndexes)")
    }
   
    func outlineView(_ outlineView: NSOutlineView, didRemove rowView: NSTableRowView, forRow row: Int) {
        print("Row removed with index: \(row). Hidden row indexs: \(outlineView.hiddenRowIndexes)")
    }
   
    func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
        print("Row view requested")
        return NSTableRowView()
    }
   
    func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
        print("Row height requested")
        return 20
    }

}


In the outline data source I have simply added a print to the draggingSession:willBegineAt call:


func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItems draggedItems: [Any]) {
        print("Dragging session will begin with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        draggedNode = draggedItems[0] as AnyObject?
        session.draggingPasteboard.setData(Data(), forType: REORDER_PASTEBOARD_TYPE)
    }


I start the app and it builds the view.

I use my "Hide index 2" menu item and get the following output


Hiding row 2

Row removed with index: -1. Hidden row indexs: 1 indexes

Row view requested

Row height requested

Row height requested

View for item called

Row height requested

Row added at index: 2. Hidden row indexs: 1 indexes


I can now check the hidden indexes with my other menu item:


Hidden row check 1 indexes


I now start to drag one of the other rows in the outline view. As I move the mouse I get the following output:


Row height requested

Dragging session will begin with hidden row indexes: 0 indexes


...and the hidden row animated into view.


Now, it is possible to hack in a re-hide of the rows in draggingSession:willBegineAt, assuming that I am tracking my own hidden items...


func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItems draggedItems: [Any]) {
        print("Dragging session will begin with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        draggedNode = draggedItems[0] as AnyObject?
        session.draggingPasteboard.setData(Data(), forType: REORDER_PASTEBOARD_TYPE)
       
        print("Hiding row in dragginSession:willBeginAt \(2)")
        theOutline.hideRows(at: [2], withAnimation: [])
    }
func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
        print("Dragging session will END with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        self.draggedNode = nil
    }

Now, I get the following output when I start dragging:


Row height requested

Dragging session will begin with hidden row indexes: 0 indexes

Hiding row in dragginSession:willBeginAt 2

Row removed with index: -1. Hidden row indexs: 1 indexes

Row view requested

Row height requested

Row height requested

View for item called

Row height requested

Row added at index: 2. Hidden row indexs: 1 indexes


Then, when dragging ends I get the following as the hidden row appears again:


Dragging session will END with hidden row indexes: 1 indexes

Row height requested


Then, when I check the indexes again using my menu item:


Hidden row check 0 indexes


So, I am a bit stumped as how to work round this. I could live with a re-hide call in draggingSessionWillBegin, but I have nowhere to catch the unhide AGAIN on end drag - since the hiddenIndexes are correct at that point, and unhidden imediately afterwards.


...and this is surely a bug.


Please let me know if there is anything else ambiguous that I am doing here.


Thanks again.

Further to the above, adding the following to draggingSession:endedAt...


func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
        print("Dragging session will END with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        self.draggedNode = nil
       
        // Delay this call a little so that everything is set up at selection time
        print("Async Hiding row in dragginSession:willBeginEnd")
        DispatchQueue.main.asyncAfter(deadline: .now()) { [unowned self] in
            theOutline.hideRows(at: [2], withAnimation: [])
        }
    }

...is almost acceptable, but there is a single frame where the row below the hidden one displays the hidden row content.


And it feels very dodgy, because I cannot see where the hiddenIndexes are actually begin reset, I can only assume it is immediately after the call to draggingSession:endedAt in the NSOutlineView.


More fundamentally - why should dragging unhide all my rows anyway?

...is almost acceptable, but there is a single frame where the row below the hidden one displays the hidden row content.


What single frame do you mean ?

But if I understand, that means that the initial problem is due to asynchronous call.


Is it an NSView based tableView ?

Doc states that

- hideRows(at:withAnimation:): Hiding a table row causes the tableView(_:didRemove:forRow:) delegate method to be invoked.

and

- tableView(_:didRemove:forRow:) method is only valid for NSView-based table views.


Why don't you simply use DispatchQueue.main.async instead of DispatchQueue.main.asyncAfter(deadline: .now()

Hey Claude.


By single frame I mean that row+1 is drawn once with hidden row item, then the next draw is OK again.


It is an NSView based tableView, yes.


The DispatchQueue call was just something so I could change the time - just experimenting.


So yes, I guess the problem is that the issue is due to an asynchronous call.


To work around this I need to hide the rows at the same point they are unhidden by the drag.

It is too early to do it in draggingSession:endAt, yet too late to do it in an async call on the main thread with no delay.

So I can't see how it is possible to work around.


(Hopefully you can see my long reply above too - it currently says it is being moderated on my end, but the later shorter post you responded to is not)

A further issue I note - which may be related.

theOutline.hideRows(at: [hideIndex], withAnimation: [])

...shows no animation.


theOutline.unhideRows(at: [hideIndex], withAnimation: [])

Does animate, although no animation is specified.


When trying to hide rows asynchronously from draggingSession:endedAt, all my hiding calls are complete before any draw calls on the cellViews or the outline.


I think I lack an understanding of how animations are layered in here, that seems to be at the heart of the issue.

Maybe that would be a poor answer to your question.


Did you try to save the hiddenRows before drag, and restore it at the end ?

Take care, you need to update because cells have been added / removed / moved. Could that be the reason why system unhides all ?

Hi Claude - yes, that is what I have been trying to do to work around the bug. The examples above show how that does not seem to be possible. I can track "hidden" on the "items" in my data model.

Where do you do the hide / unhide ? It would help to have a global view on this part of code where you manage the outlineView.

Thank you for all your feedback Claude.

I posted the following as the second item in this thread, however it seems to have got stuck at moderation.

Posting again with a github link removed....

_____________________________


I first noticed the issue in my large project, but was able to reproduce it quickly in someone else's simple example project from GitHub called "OutlineViewReorder" - that you Thierry!


To repro, I added the following items to the Edit menu:

- Hide row index 2

- Check hidden indexes

- Unhide row index 2


These link to the following methods on the view controller as the first responder:


@IBAction func hideRow(_ sender: AnyObject) {
        print("Hiding row \(hideIndex)")
        theOutline.hideRows(at: [hideIndex], withAnimation: [])
    }
   
    @IBAction func checkHiddenRows(_ sender: AnyObject) {
        print("Hidden row check \(theOutline.hiddenRowIndexes)")
    }
   
    @IBAction func unhideRow(_ sender: AnyObject) {
        print("unHiding row \(hideIndex)")
        theOutline.unhideRows(at: [hideIndex], withAnimation: [])
    }


I updated the outline view delegate to the following:


extension ViewController: NSOutlineViewDelegate {
   
    func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
        let cell = outlineView.make(withIdentifier: "OutlineColItem", owner: self) as! OutlineItemView
       
        cell.textField!.delegate = self
       
        if let folderItem = item as? FolderItem
        {
            cell.textField!.stringValue = folderItem.name
            cell.imageView!.image = folderImage
        }
        else if let aItem = item as? TestItem
        {
            cell.textField!.stringValue = aItem.name
            cell.imageView!.image = itemImage
        }
       
        print("View for item called")
       
        return cell
    }
   
    func outlineView(_ outlineView: NSOutlineView, didAdd rowView: NSTableRowView, forRow row: Int) {
        print("Row added at index: \(row). Hidden row indexs: \(outlineView.hiddenRowIndexes)")
    }
   
    func outlineView(_ outlineView: NSOutlineView, didRemove rowView: NSTableRowView, forRow row: Int) {
        print("Row removed with index: \(row). Hidden row indexs: \(outlineView.hiddenRowIndexes)")
    }
   
    func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
        print("Row view requested")
        return NSTableRowView()
    }
   
    func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
        print("Row height requested")
        return 20
    }

}


In the outline data source I have simply added a print to the draggingSession:willBegineAt call:


func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItems draggedItems: [Any]) {
        print("Dragging session will begin with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        draggedNode = draggedItems[0] as AnyObject?
        session.draggingPasteboard.setData(Data(), forType: REORDER_PASTEBOARD_TYPE)
    }


I start the app and it builds the view.

I use my "Hide index 2" menu item and get the following output


Hiding row 2

Row removed with index: -1. Hidden row indexs: 1 indexes

Row view requested

Row height requested

Row height requested

View for item called

Row height requested

Row added at index: 2. Hidden row indexs: 1 indexes


I can now check the hidden indexes with my other menu item:


Hidden row check 1 indexes


I now start to drag one of the other rows in the outline view. As I move the mouse I get the following output:


Row height requested

Dragging session will begin with hidden row indexes: 0 indexes


...and the hidden row animated into view.


Now, it is possible to hack in a re-hide of the rows in draggingSession:willBegineAt, assuming that I am tracking my own hidden items...


func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItems draggedItems: [Any]) {
        print("Dragging session will begin with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        draggedNode = draggedItems[0] as AnyObject?
        session.draggingPasteboard.setData(Data(), forType: REORDER_PASTEBOARD_TYPE)
       
        print("Hiding row in dragginSession:willBeginAt \(2)")
        theOutline.hideRows(at: [2], withAnimation: [])
    }
func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
        print("Dragging session will END with hidden row indexes: \(outlineView.hiddenRowIndexes)")
        self.draggedNode = nil
    }

Now, I get the following output when I start dragging:


Row height requested

Dragging session will begin with hidden row indexes: 0 indexes

Hiding row in dragginSession:willBeginAt 2

Row removed with index: -1. Hidden row indexs: 1 indexes

Row view requested

Row height requested

Row height requested

View for item called

Row height requested

Row added at index: 2. Hidden row indexs: 1 indexes


Then, when dragging ends I get the following as the hidden row appears again:


Dragging session will END with hidden row indexes: 1 indexes

Row height requested


Then, when I check the indexes again using my menu item:


Hidden row check 0 indexes


So, I am a bit stumped as how to work round this. I could live with a re-hide call in draggingSessionWillBegin, but I have nowhere to catch the unhide AGAIN on end drag - since the hiddenIndexes are correct at that point, and unhidden imediately afterwards.


...and this is surely a bug.


Please let me know if there is anything else ambiguous that I am doing here.


Thanks again.

You could edit the gitHub link by adding spaces in h ttps, to avoid delays of moderation ; I will test the code.


What happens very often is async call to some functions ; there is a request pending in another queue that will finally clear your hiddenRows array. One way to check would be to dispatch a call to a closure to rebuild this array, with some delay (just to see).

NSTableView hideRows not behaving as documented
 
 
Q