Search code examples
swiftmacosnsoutlineview

Why View-Based NSOutlineView with autosaveExpandedItems true ignores expanded upon reloadData?


I use a NSOutlineView that auto saves expanded state. If I manually reload data when dataSource updates, the func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? datasource method is not called anymore and every cell collapses. Any idea why this might happen?

Tried to reloadItem with nil send as param but still no good.

I use this for persisting expanded rows:

func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
    return NSKeyedArchiver.archivedData(withRootObject: item)
}

func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
    guard let data = object as? Data,
        let item = NSKeyedUnarchiver.unarchiveObject(with: data) as? Category else { return nil }
    let foundItem = recursiveSearch(for: item, in: viewModel.dataSource.value)
    return foundItem
}

And this to reloadData:

  viewModel.dataSource.subscribe(onNext: { [weak self] _ in
            self?.outlineView.reloadData()
  }).disposed(by: disposeBag)

Solution

  • IMHO autosaving is sort of half-baked feature and it doesn't work as expected. In other words, it's implemented in a way that it restores the state when your application launches (just once) and then you're on your own.

    Implement your own one utilizing outlineViewItemDidExpand(_:) & outlineViewItemDidCollapse(_:) (especially when we're reloading, ...).

    Couple of tricks you can use if you do not want to implement custom autosaving. But I wouldn't rely on them.

    First trick - tell the NSOutlineView to reload persistent state

    NSOutlineView inherits from the NSTableView and the autosaveName property documentation says:

    If you change the value of this property to a new name, the table reads in any saved information and sets the order and width of this table view’s columns to match. Setting the name to nil removes any previously stored state from the user defaults.

    What is inaccurate here - setting it to nil doesn't remove previously stored expanded items state for NSOutlineView. We can use it to force the NSOutlineView to reload expanded items state:

    class ViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource {
        @IBOutlet var outlineView: NSOutlineView!
        // It's for testing, to demonstrate the persistent state reloading
        private var doNotLoad = true
    
        override func viewDidAppear() {
            super.viewDidAppear()
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                self.doNotLoad = false
                
                let autosaveName = self.outlineView.autosaveName
                self.outlineView.autosaveName = nil            
                self.outlineView.reloadData()
                self.outlineView.autosaveName = autosaveName
            }
        }
    
        func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
            if (doNotLoad) {
                return 0
            }
            return item == nil ? data.count : (item as! Node).children.count
        }
    }
    

    If you'd like to comply with the documentation, do not use nil and set some fake name. But I would expect that once the bug is fixed, the persistent state will be removed if we change the autosaveName or if we set it set to nil.

    Second trick - load & expand yourself

    Imagine you have the following Node class:

    class Node {
        let id: Int
        let children: [Node]
        // ...
    }
    

    And your data source implements:

    func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
        (item as! Node).id
    }
    
    func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
        guard let id = object as? Int else { return nil }
        return data.firstNode { $0.id == id }
    }
    

    The firstNode is not related to this question, but here's the implementation (because it's mentioned in the code):

    extension Array where Self.Element == Node {
        // Search for a node (recursively) until a matching element is found
        func firstNode(where predicate: (Element) throws -> Bool) rethrows -> Element? {
            for element in self {
                if try predicate(element) {
                    return element
                }
                if let matched = try element.children.firstNode(where: predicate) {
                    return matched
                }
            }
            return nil
        }
    }
    

    Then you can reloadData & expand all the items by yourself:

    outlineView.reloadData()
    if outlineView.autosaveExpandedItems,
       let autosaveName = outlineView.autosaveName,
       let persistentObjects = UserDefaults.standard.array(forKey: "NSOutlineView Items \(autosaveName)"),
       let itemIds = persistentObjects as? [Int] {
        
        itemIds.forEach {
            let item = outlineView.dataSource?.outlineView?(self.outlineView, itemForPersistentObject: $0)
            self.outlineView.expandItem(item)
        }
    }