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...
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?
What is a source list? It's NSOutlineView
(which is a subclass of
NSTableView
) with a special
treatment. Finder screenshot:
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
.
Xcode:
FinderLikeSidebar
there)
NSOutlineView Items FinderLikeSidebar
key@IBOutlet var outlineView: NSOutlineView!
GroupCell
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.
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 }
}
}