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"?
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