How to style SwiftUI sidebar row selections like native macOS apps (Finder, Photos)

https://gist.github.com/MorusPatre/4b1e93973c3e4133794512fd7eefee48

This Is a Test App to find out how to actually achieve the exact sidebar styling Apple uses for Finder, Photos etc. The crucial part is how do I make it so the symbol and name of the selected row use the accent colour with active and inactive styling rather than having the accent colour for the row background? It shouldn't be that complicated I feel like but every AI model (even Claude Fable 5) fails at that and I haven't found apps or videos where that is explained so is that just a classic case of "Apple doesn't want you to know"?

Answered by DTS Engineer in 896235022

Hello @Moretheus,

You have a good start!

While I can't confirm exactly how Apple achieved a certain goal, I can show you how you can get the same result in your starting project.

What you are noticing is that NSOutlineView.style = .sourceList displays the selection highlight using the system accent color. To set your own color, first disable the built-selection highlight by setting outline.selectionHighlightStyle = .none in makeNSView.

Then create a separate view for the row that you can style to your liking. I used drawBackground to paint the selection and interiorBackgroundStyle to disable the SF symbol inverting to white when selected.

final class SidebarRowView: NSTableRowView {
    override var isSelected: Bool { didSet { needsDisplay = true } }

    override func drawBackground(in dirtyRect: NSRect) {
        if isSelected {
            NSColor.systemGray
                .withAlphaComponent(0.2)
                .setFill()
            NSBezierPath(roundedRect: bounds.insetBy(dx: 8, dy: 1), xRadius: 5, yRadius: 5).fill()
        }
    }

    override var interiorBackgroundStyle: NSView.BackgroundStyle { .normal }
}

For bold text on selection, I set the font in outlineView and reload rows on selection change.

Take a look here, I think it looks pretty accurate!

Another consideration is SwiftUI and AppKit and different sidebar and list APIs react slightly differently here as well. Feel free to experiment with different API as well.

Result:

And here's what was updated:

final class SidebarRowView: NSTableRowView {
...
}

struct SourceListSidebar: NSViewRepresentable {
    let sections: [SidebarSection]
    @Binding var selection: SidebarItem?

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let outline = NSOutlineView()
        outline.style = .sourceList
        outline.rowSizeStyle = .default
        outline.floatsGroupRows = false
        outline.headerView = nil
        outline.focusRingType = .none
        outline.allowsEmptySelection = true
        outline.allowsMultipleSelection = false
        outline.selectionHighlightStyle = .none
        outline.backgroundColor = .clear
        outline.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle

        let column = NSTableColumn(identifier: .init("main"))
        outline.addTableColumn(column)
        outline.outlineTableColumn = column

        outline.dataSource = context.coordinator
        outline.delegate = context.coordinator
        outline.reloadData()
        outline.expandItem(nil, expandChildren: true)

        let scroll = NSScrollView()
        scroll.documentView = outline
        scroll.hasVerticalScroller = true
        scroll.drawsBackground = false
        return scroll
    }

    func updateNSView(_ scroll: NSScrollView, context: Context) {
        context.coordinator.parent = self
        guard let outline = scroll.documentView as? NSOutlineView else { return }
        if let selection {
            let row = outline.row(forItem: selection)
            if row >= 0, outline.selectedRow != row {
                outline.selectRowIndexes([row], byExtendingSelection: false)
            }
        } else if outline.selectedRow >= 0 {
            outline.deselectAll(nil)
        }
    }

    final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate {
        var parent: SourceListSidebar

        init(_ parent: SourceListSidebar) {
            self.parent = parent
        }

        // MARK: Data source

        func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
            guard let section = item as? SidebarSection else { return parent.sections.count }
            return section.items.count
        }

        func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
            guard let section = item as? SidebarSection else { return parent.sections[index] }
            return section.items[index]
        }

        func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
            item is SidebarSection
        }

        // MARK: Delegate

        func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
            item is SidebarSection
        }

        func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
            item is SidebarItem
        }

        func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
            if let section = item as? SidebarSection {
                let cell = headerCell(outlineView)
                cell.textField?.stringValue = section.name
                return cell
            }
            guard let item = item as? SidebarItem else { return nil }
            let cell = itemCell(outlineView)
            cell.textField?.stringValue = item.name
            cell.textField?.font = item === parent.selection
                ? .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
                : .systemFont(ofSize: NSFont.systemFontSize)
            cell.imageView?.image = NSImage(systemSymbolName: item.icon, accessibilityDescription: item.name)
            return cell
        }

        func outlineViewSelectionDidChange(_ notification: Notification) {
            guard let outline = notification.object as? NSOutlineView else { return }
            let item = outline.item(atRow: outline.selectedRow) as? SidebarItem
            if parent.selection !== item {
                parent.selection = item
            }
            outline.reloadData(forRowIndexes: IndexSet(0..<outline.numberOfRows), columnIndexes: IndexSet(integer: 0))
        }

        // MARK: [New] Row views

        func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
            guard item is SidebarItem else { return nil }
            return SidebarRowView()
        }

        // MARK: Cells
        ...
        }
    }
}

I hope this information is helpful.

 Travis

Hello @Moretheus,

You have a good start!

While I can't confirm exactly how Apple achieved a certain goal, I can show you how you can get the same result in your starting project.

What you are noticing is that NSOutlineView.style = .sourceList displays the selection highlight using the system accent color. To set your own color, first disable the built-selection highlight by setting outline.selectionHighlightStyle = .none in makeNSView.

Then create a separate view for the row that you can style to your liking. I used drawBackground to paint the selection and interiorBackgroundStyle to disable the SF symbol inverting to white when selected.

final class SidebarRowView: NSTableRowView {
    override var isSelected: Bool { didSet { needsDisplay = true } }

    override func drawBackground(in dirtyRect: NSRect) {
        if isSelected {
            NSColor.systemGray
                .withAlphaComponent(0.2)
                .setFill()
            NSBezierPath(roundedRect: bounds.insetBy(dx: 8, dy: 1), xRadius: 5, yRadius: 5).fill()
        }
    }

    override var interiorBackgroundStyle: NSView.BackgroundStyle { .normal }
}

For bold text on selection, I set the font in outlineView and reload rows on selection change.

Take a look here, I think it looks pretty accurate!

Another consideration is SwiftUI and AppKit and different sidebar and list APIs react slightly differently here as well. Feel free to experiment with different API as well.

Result:

And here's what was updated:

final class SidebarRowView: NSTableRowView {
...
}

struct SourceListSidebar: NSViewRepresentable {
    let sections: [SidebarSection]
    @Binding var selection: SidebarItem?

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let outline = NSOutlineView()
        outline.style = .sourceList
        outline.rowSizeStyle = .default
        outline.floatsGroupRows = false
        outline.headerView = nil
        outline.focusRingType = .none
        outline.allowsEmptySelection = true
        outline.allowsMultipleSelection = false
        outline.selectionHighlightStyle = .none
        outline.backgroundColor = .clear
        outline.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle

        let column = NSTableColumn(identifier: .init("main"))
        outline.addTableColumn(column)
        outline.outlineTableColumn = column

        outline.dataSource = context.coordinator
        outline.delegate = context.coordinator
        outline.reloadData()
        outline.expandItem(nil, expandChildren: true)

        let scroll = NSScrollView()
        scroll.documentView = outline
        scroll.hasVerticalScroller = true
        scroll.drawsBackground = false
        return scroll
    }

    func updateNSView(_ scroll: NSScrollView, context: Context) {
        context.coordinator.parent = self
        guard let outline = scroll.documentView as? NSOutlineView else { return }
        if let selection {
            let row = outline.row(forItem: selection)
            if row >= 0, outline.selectedRow != row {
                outline.selectRowIndexes([row], byExtendingSelection: false)
            }
        } else if outline.selectedRow >= 0 {
            outline.deselectAll(nil)
        }
    }

    final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate {
        var parent: SourceListSidebar

        init(_ parent: SourceListSidebar) {
            self.parent = parent
        }

        // MARK: Data source

        func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
            guard let section = item as? SidebarSection else { return parent.sections.count }
            return section.items.count
        }

        func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
            guard let section = item as? SidebarSection else { return parent.sections[index] }
            return section.items[index]
        }

        func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
            item is SidebarSection
        }

        // MARK: Delegate

        func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
            item is SidebarSection
        }

        func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
            item is SidebarItem
        }

        func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
            if let section = item as? SidebarSection {
                let cell = headerCell(outlineView)
                cell.textField?.stringValue = section.name
                return cell
            }
            guard let item = item as? SidebarItem else { return nil }
            let cell = itemCell(outlineView)
            cell.textField?.stringValue = item.name
            cell.textField?.font = item === parent.selection
                ? .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
                : .systemFont(ofSize: NSFont.systemFontSize)
            cell.imageView?.image = NSImage(systemSymbolName: item.icon, accessibilityDescription: item.name)
            return cell
        }

        func outlineViewSelectionDidChange(_ notification: Notification) {
            guard let outline = notification.object as? NSOutlineView else { return }
            let item = outline.item(atRow: outline.selectedRow) as? SidebarItem
            if parent.selection !== item {
                parent.selection = item
            }
            outline.reloadData(forRowIndexes: IndexSet(0..<outline.numberOfRows), columnIndexes: IndexSet(integer: 0))
        }

        // MARK: [New] Row views

        func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
            guard item is SidebarItem else { return nil }
            return SidebarRowView()
        }

        // MARK: Cells
        ...
        }
    }
}

I hope this information is helpful.

 Travis

How to style SwiftUI sidebar row selections like native macOS apps (Finder, Photos)
 
 
Q