Search code examples
macosappkitnsoutlineviewmacos-big-sur

Big Sur outline view expandable items broken


I've started a new macOS project (currently on Big Sur beta 3), and the NSOutlineView nodes seem to be broken. Can't tell if this is me or the os.

Here's a sample project that demonstrates the issue. And an image...

enter image description here

As you can see, the cell is overlapping the expansion chevrons. Clicking on either chevron restores the first row to the proper layout, but not the second. Also, the autosave methods persistentObjectForItem and itemForPersistentObject are never called.

The test project is super simple--all I did was add the SourceView component from the view library to the default app project and hook up the delegate/data source to the view controller. Also checked Autosave Expanded Items in IB and put a name in the Autosave field. Here's the entirety of the controller code:

class ViewController: NSViewController {
    @IBOutlet var outlineView: NSOutlineView?

    let data = [Node("First item", 1), Node("Second item", 2)]
}

extension ViewController: NSOutlineViewDataSource {
    func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
        data[index]
    }
    
    func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
        true
    }
    
    func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
        item == nil ? data.count : 0
    }
    
    func outlineView(_ outlineView: NSOutlineView, objectValueFor tableColumn: NSTableColumn?, byItem item: Any?) -> Any? {
        item
    }
    
    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.first { $0.id == id }
    }
}


extension ViewController: NSOutlineViewDelegate {
    func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
        guard let node = item as? Node else {
            preconditionFailure("Invalid data item \(item)")
        }
        let view = outlineView.makeView(withIdentifier: nodeCellIdentifier, owner: self) as? NSTableCellView
        view?.textField?.stringValue = node.name
        view?.imageView?.image = NSImage(systemSymbolName: node.icon, accessibilityDescription: nil)
        return view
    }
}


final class Node {
    let id: Int
    let name: String
    let icon: String
    
    init(_ name: String, _ id: Int, _ icon: String = "folder") {
        self.id = id
        self.name = name
        self.icon = icon
    }
}

private let nodeCellIdentifier = NSUserInterfaceItemIdentifier("DataCell")

Any Mac developers left out there that can help?


Solution

  • Source list

    What is a source list? It's NSOutlineView (which is a subclass of NSTableView) with a special treatment. Finder screenshot:

    enter image description here

    To create a source list, all you have to do is to set the selectionHighlightStyle property to .sourceList. The documentation says:

    The source list style of NSTableView. On 10.5, a light blue gradient is used to highlight selected rows.

    What it does exactly? Jump to Definition in Xcode and read comments (not included in the docs):

    The source list style of NSTableView. On 10.10 and higher, a blur selection is used to highlight rows. Prior to that, a light blue gradient was used. Note: Cells that have a drawsBackground property should have it set to NO. Otherwise, they will draw over the highlighting that NSTableView does. Setting this style will have the side effect of setting the background color to the "source list" background color. Additionally in NSOutlineView, the following properties are changed to get the standard "source list" look: indentationPerLevel, rowHeight and intercellSpacing. After calling setSelectionHighlightStyle: one can change any of the other properties as required. In 10.11, if the background color has been changed from the "source list" background color to something else, the table will no longer draw the selection as a source list blur style, and instead will do a normal blue highlight.

    Since you're on Big Sur, be aware that the SelectionHighlightStyle.sourceList is deprecated. One should use style & effectiveStyle.

    Sample project

    Xcode:

    • New project
      • macOS & App (Storyboard & AppKit App Delegate & Swift)
    • Main.storyboard
      • Add Source List control
        • Position & fix constraints
        • Set delegate & dataSource to ViewController
        • Enable Autosave Expanded Items
        • Set Autosave to whatever you want (I have FinderLikeSidebar there)
          • Choose wisely because the expansion state is saved in the user defaults under the NSOutlineView Items FinderLikeSidebar key
        • Create @IBOutlet var outlineView: NSOutlineView!
      • Add another Text Table Cell View (no image)
        • Set identifier to GroupCell
    • ViewController.swift
      • Commented code below

    Screenshots

    enter image description here

    As you can see, it's almost Finder like - 2nd level is still indented. The reason for this is that the Documents node is expandable (has children). I have them here to demonstrate autosaving.

    Just remove them if you'd like to move all 2nd level nodes to the left.

    enter image description here

    ViewController.swift code

    There's not much to say about it except - read comments :)

    import Cocoa
    
    // Sample Node class covering groups & regular items
    class Node {
        let id: Int
        let title: String
        let symbolName: String?
        let children: [Node]
        let isGroup: Bool
        
        init(id: Int, title: String, symbolName: String? = nil, children: [Node] = [], isGroup: Bool = false) {
            self.id = id
            self.title = title
            self.symbolName = symbolName
            self.children = children
            self.isGroup = isGroup
        }
        
        convenience init(groupId: Int, title: String, children: [Node]) {
            self.init(id: groupId, title: title, children: children, isGroup: true)
        }
    }
    
    extension Node {
        var cellIdentifier: NSUserInterfaceItemIdentifier {
            // These must match identifiers in Main.storyboard
            NSUserInterfaceItemIdentifier(rawValue: isGroup ? "GroupCell" : "DataCell")
        }
    }
    
    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
        }
    }
    
    class ViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource {
        @IBOutlet var outlineView: NSOutlineView!
        
        let data = [
            Node(groupId: 1, title: "Favorites", children: [
                Node(id: 11, title: "AirDrop", symbolName: "wifi"),
                Node(id: 12, title: "Recents", symbolName: "clock"),
                Node(id: 13, title: "Applications", symbolName: "hammer")
            ]),
            Node(groupId: 2, title: "iCloud", children: [
                Node(id: 21, title: "iCloud Drive", symbolName: "icloud"),
                Node(id: 22, title: "Documents", symbolName: "doc", children: [
                    Node(id: 221, title: "Work", symbolName: "folder"),
                    Node(id: 221, title: "Personal", symbolName: "folder.badge.person.crop"),
                ])
            ]),
        ]
        
        override func viewWillAppear() {
            super.viewWillAppear()
            
            // Expanded items are saved in the UserDefaults under the key:
            //
            // "NSOutlineView Items \(autosaveName)"
            //
            // By default, this value is not present. When you expand some nodes,
            // an array with persistent objects is saved. When you collapse all nodes,
            // the array is removed from the user defaults (not an empty array,
            // but back to nil = removed).
            //
            // IOW there's no way to check if user already saw this source list,
            // modified expansion state, etc. We will use custom key for this
            // purpose, so we can expand group nodes (top level) when the source
            // list is displayed for the first time.
            //
            // Next time, we wont expand anything and will honor autosaved expanded
            // items.
            if UserDefaults.standard.object(forKey: "FinderLikeSidebarAppeared") == nil {
                data.forEach {
                    outlineView.expandItem($0)
                }
                UserDefaults.standard.set(true, forKey: "FinderLikeSidebarAppeared")
            }
        }
        
        // Number of children or groups (item == nil)
        func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
            item == nil ? data.count : (item as! Node).children.count
        }
        
        // Child of a node or group (item == nil)
        func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
            item == nil ? data[index] : (item as! Node).children[index]
        }
        
        // View for our node
        func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
            guard let node = item as? Node,
                  let cell = outlineView.makeView(withIdentifier: node.cellIdentifier, owner: self) as? NSTableCellView else {
                return nil
            }
            
            cell.textField?.stringValue = node.title
            
            if !node.isGroup {
                cell.imageView?.image = NSImage(systemSymbolName: node.symbolName ?? "folder", accessibilityDescription: nil)
            }
            
            return cell
        }
    
        // Mark top level items as group items
        func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
            (item as! Node).isGroup
        }
        
        // Every node is expandable if it has children
        func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
            !(item as! Node).children.isEmpty
        }
        
        // Top level items (group items) are not selectable
        func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
            !(item as! Node).isGroup
        }
        
        // Object to save in the user defaults (NSOutlineView Items FinderLikeSidebar)
        func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
            (item as! Node).id
        }
        
        // Find an item from the saved object (NSOutlineView Items FinderLikeSidebar)
        func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
            guard let id = object as? Int else { return nil }
            return data.firstNode { $0.id == id }
        }
    }