Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

NSOutlineView on macOS: Stable Tree Identity, Expansion, and Lazy Children

Build reliable AppKit outlines with stable node objects, cached child lookup, lazy expansion, persisted state, and safe updates to hierarchical data.

NSOutlineView presents hierarchical data using rows and columns, but it does not own the tree. It asks a data source for children and values as users expand, collapse, edit, and navigate. The model must provide stable identity and efficient child lookup. If a node object is recreated on each callback, the outline can lose its expansion state, selection, and mapping from visible rows back to the domain model.

Apple documents an important identity rule: outline items must be unique, and preserving collapsed state across reloads requires both the same item pointer and consistent isEqual(_:) identity. Equality alone does not replace pointer stability. A filename or display title is not enough for identity because two directories can contain an entry with the same name. Use a durable domain ID and retain a canonical node object for each live model item across reloads.

Model the tree separately from visible rows

The tree should answer child count, child lookup, expandability, and value requests without scanning every sibling on each callback. Outline data-source methods can be called frequently, so keep them bounded and predictable. Precompute child arrays or indexes when the model changes, and return the child at the requested offset in constant or logarithmic time.

import Foundation

final class TreeNode: NSObject {
    let id: UUID
    var title: String
    var children: [TreeNode]
    var mayHaveUnloadedChildren: Bool

    init(id: UUID = UUID(), title: String,
         children: [TreeNode] = [], mayHaveUnloadedChildren: Bool = false) {
        self.id = id
        self.title = title
        self.children = children
        self.mayHaveUnloadedChildren = mayHaveUnloadedChildren
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let other = object as? TreeNode else { return false }
        return id == other.id
    }

    override var hash: Int { id.hashValue }
}

The immutable UUID supplies stable equality and hash behavior while mutable presentation data changes. If IDs are persisted across launches, load the same ID back into the model. Do not change an object’s hash while it is used in a set or dictionary. A root sentinel can be handled separately because the data source uses nil to represent the root item.

Data-source boundaries and loading

With a conventional data source, implement the basic child count, child retrieval, expandability, and object-value methods. nil parent means the root. Return the same item object for repeated lookup of a given node in one model generation. If a data source is wired in Interface Builder, AppKit may query it before awakeFromNib; respond with an empty root until the model is configured, then call reloadData() as the official API guidance describes.

Child lookup should be a pure read. Do not perform synchronous disk access or network requests in numberOfChildrenOfItem or child(_:ofItem:). For a remote hierarchy, represent “children not loaded yet” in the model. The parent can be expandable before its children are loaded; when the user expands it, start an asynchronous load, show an explicit loading state if useful, and update the model on its owning actor when the result arrives.

Because the outline data source is synchronous, asynchronous loading needs a transition policy. One approach uses a stable placeholder child with a distinct node kind. Another uses the expansion delegate callback to initiate work while the node currently reports potential children. Do not return a fake placeholder with the same identity as a real child. When the actual data arrives, replace the child list atomically and reload only that item or its descendants.

Expansion state and persisted identity

Expansion belongs to the user’s view state, not necessarily to the underlying hierarchy. Persist stable node IDs or application-defined paths only if restoring expansion materially helps the workflow. A path made from names is fragile when nodes can be renamed or duplicated. For virtual filesystems, include the provider or volume identity as part of the key.

When refreshing the tree, reconcile nodes by ID and update their properties in place where possible. Recreating every object after a refresh can make unchanged branches appear to the outline as new items. If the identity schema must change, migrate expanded and selected IDs deliberately or accept that the saved state can no longer be mapped.

Choose reload scope according to the mutation. A value-only edit should not need a complete tree rebuild, while a parent-child move needs both affected branches reconciled before the view is notified. Keep model mutation and UI notification in one serialized update so data-source callbacks never observe a half-moved node or two parents claiming the same child.

Do not preserve expansion for a node that is no longer visible or authorized in the current model. If an item was deleted, remove it from the persisted expansion set. If a parent is temporarily unloaded, retain only state that can be safely re-resolved when the parent returns.

Selection and row indexes

Visible row indexes are presentation positions that change when branches expand, collapse, or update. Store selected node IDs in the model or controller, and derive visible row numbers through the outline view when performing UI actions. A row number captured before an asynchronous load can refer to a different node afterward.

When a selected child disappears, choose a documented fallback: select its nearest surviving ancestor, move to a sibling, or clear selection. Restore selection only after the tree has been reloaded and the node can be resolved. Avoid selection callbacks that write back the same selection and cause a refresh loop.

Drag, edit, and mutation semantics

Editing a title should update the model and let the outline redisplay the affected item; it should not change the node’s identity. If a child is moved between parents, preserve its stable ID and update the source and destination child arrays as one transaction. Validate that the move does not create a cycle and that the target container accepts that node kind.

For drag and drop, distinguish a proposed child index from the currently visible row. Validate the destination parent, permissions, type rules, and current tree generation before committing. On failure, leave the original hierarchy intact and return a rejection rather than half-updating one parent’s array.

NSOutlineView supports view-based and cell-based content. In a view-based outline, configure reused row views deterministically and clear any state that does not apply to the new item. Accessibility labels, disclosure state, and keyboard focus should reflect the same model revision as the visible hierarchy.

Memory, complexity, and tree scale

An outline can display a small visible portion of a much larger tree, but the data source still controls what it loads and retains. Decide whether child nodes are cached, paged, or evicted after collapse. If unloaded nodes are represented by placeholders, maintain a clear transition to the canonical child list. Avoid rebuilding a full tree for every changed leaf; update a branch when the model supports a stable diff.

Measure child lookup latency, reload duration, memory retained by collapsed branches, and expansion delay at realistic depth and fan-out. Deep hierarchies can create recursive work in rendering, accessibility traversal, or serialization. Use bounded recursion or an explicit stack for untrusted or extremely deep data.

Diagnostics and acceptance tests

Test duplicate labels under different parents, stable IDs after refresh, expansion preservation, deletion of an expanded node, lazy-child success and failure, parent refresh during loading, move between parents, cycle rejection, model reset before nib loading, and selection restoration after reorder. Assert that each ID occurs only once in the current tree and every returned child belongs to the requested parent.

Log tree generation, node ID, parent ID, child count, load state, expansion transition, and duration. Avoid logging private node titles or file paths by default. When an outline appears to forget its state, compare the object identities and equality behavior returned before and after reload rather than adding manual expansion calls first.

A robust outline keeps a canonical tree model, stable node identity, inexpensive synchronous data-source methods, and explicit asynchronous loading transitions. AppKit manages visible hierarchy presentation; the application defines what each node means and how it survives change.

Related:

Sources:

Comments