Search code examples
swiftmacoscocoansoutlineview

In a macOS Cocoa Application that has two identical NSOutlineViews side by side, is there a way to expand / collapse items in sync between the two?


I am developing a macOS Cocoa Application in Swift 5.1 . In the main window, I have two identical NSOutlineViews that have the same contents exactly. I would like to enable a sync mode, where if items are expanded/collapsed in one of the two NSOutlineViews, the corresponding item is expanded/collapsed in the other simultaneously. I tried to do this by implementing shouldExpandItem and shouldCollapseItem in the delegate. The delegate is the same object for both NSOutlineViews, and I have outlets that reference the two NSOutlineViews for distinguishing the two. The problem is, if I call expandItem programmatically in shouldExpandItem, the method is again called for the other NSOutlineView, leading to an infinite loop and a stack overflow. I have found a dirty solution that works, by momentarily setting the delegate of the relevant NSOutlineView to nil, expand/collapse the item, then set the delegate back. The code is the following:

func outlineView(_ outlineView: NSOutlineView, shouldExpandItem item: Any) -> Bool {

    let filePath = (item as! FileSystemObject).fullPath

    let trueItem = item as! FileSystemObject

    trueItem.children = Array()

    do {
        let contents = try FileManager.default.contentsOfDirectory(atPath: filePath!)

        for (_, item) in contents.enumerated() {

            let entry = FileSystemObject.init()
            entry.fullPath = URL.init(fileURLWithPath: filePath!).appendingPathComponent(item).path
            if entry.exists {
                trueItem.children.append(entry)
            }
        }

    } catch {

    }

        if outlineView == self.leftOutlineView {
            self.rightOutlineView.delegate = nil;
            self.rightOutlineView.expandItem(item)
            self.rightOutlineView.delegate = self;
        } else {
            self.leftOutlineView.delegate = nil;
            self.leftOutlineView.expandItem(item)
            self.leftOutlineView.delegate = self;
    }

    return true

}

func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool {

    if outlineView == self.leftOutlineView {
        self.rightOutlineView.delegate = nil;
        self.rightOutlineView.collapseItem(item)
        self.rightOutlineView.delegate = self;
    } else {
        self.leftOutlineView.delegate = nil;
        self.leftOutlineView.collapseItem(item)
        self.leftOutlineView.delegate = self;

    }

    return true
}

It seems to work, but I am afraid that something could go wrong with this approach. Is setting the delegate momentarily to nil a possible solution, or should I be aware of any caveats ? is there another pattern that you can suggest to achieve this ? Thanks

EDIT: According to below comments and answers

I found the simplest solution thanks to the answers / comments I received. Instead of implementing the sync logic in the outlineViewShouldExpand/Collapse methods, it is possible to implement outlineViewDidExpand and outlineViewDidCollapse and place the sync logic there. the latter methods are not called when expanding/collapsing items programmatically, so there is no risk of infinite loop or stack overflow.

The code is as follows:

func outlineViewItemDidExpand(_ notification: Notification) {

    let outlineView = notification.object as! NSOutlineView

    let userInfo = notification.userInfo as! Dictionary<String, Any>

    let item = userInfo["NSObject"]

    if outlineView == self.leftOutlineView {
              self.rightOutlineView.animator().expandItem(item)
        } else {
              self.leftOutlineView.animator().expandItem(item)

    }
}

func outlineViewItemDidCollapse(_ notification: Notification) {

      let outlineView = notification.object as! NSOutlineView

      let userInfo = notification.userInfo as! Dictionary<String, Any>

      let item = userInfo["NSObject"]

      if outlineView == self.leftOutlineView {
                self.rightOutlineView.animator().collapseItem(item)
          } else {
                self.leftOutlineView.animator().collapseItem(item)

      }
  }

Furthermore, now, I cannot understand why, the expansion / collapse of items is animated, which did not work with my original approach. I hope this can be helpful for somebody, it was very helpful to me. Thanks a lot.


Solution

  • outlineView(_:shouldExpandItem:) is called before the item is expanded and calling expandItem() back and forth causes an infite loop. outlineViewItemDidExpand(_:) is called after the item is expanded and NSOutlineView.expandItem(_:) does nothing when the item is already expanded (documented behaviour). When expanding the left outline view, expandItem() of the right outline view does call outlineViewItemDidExpand(_:) but the expandItem() of the left outline view doensn't call outlineViewItemDidExpand(_:) again.

    func outlineViewItemDidExpand(_ notification: Notification) {
        let outlineView = notification.object as! NSOutlineView
        let item = notification.userInfo!["NSObject"]
        if outlineView == self.leftOutlineView {
            self.rightOutlineView.animator().expandItem(item)
        } else {
            self.leftOutlineView.animator().expandItem(item)
        }
    }