Skip to content

Latest commit

 

History

60 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MacAppSettingsUI

A package for building settings / preferences UI in macOS AppKit-based apps.

Requirements

macOS 15.6 or later.

Panes are loaded through Swift Concurrency, so a pane that loads its content asynchronously is written with async/await.

Install

Use SwiftPM.

Breaking Changes

2.0.0

1.x 2.0.0 (new)
loadPaneContent(completion:) loadPaneContent() async throws

Pane content is no longer built in loadView(). It goes into buildPaneContent(), or into loadPaneContent() when it has to be awaited, and the library calls whichever you overrode the first time that tab is selected. Keeping the build in loadView() still compiles, but the pane is then built up front and measured before it belongs to a window.

Leaving a pane before its load has finished now cancels it. If the awaited call throws, the pane is left unloaded and built again on the next visit. See Loading Pane Content.

Everything else is additive: buildPaneContent(), paneContentDidLoad(), paneContentDidFailToLoad(_:), SettingsTabLayoutView, SettingsHostingView, the indentation arguments, tab restoration and the pane dissolve. Existing calls keep working.

Design and Features

Preferences-Style Toolbar with Animation

The window has a preferences-style toolbar and the native switching animation. It also supports the “Reduce Motion” accessibility setting.

Window Title

The name of the active pane becomes the window title automatically when panes are switched.

Window Title with Active Pane Name on Window Menu

The Window menu shows that same title, so the active pane is named there too.

Only Close Button

The window normally has only a close button. A zoom button can be added per pane.

Press Escape Key to Close

The Escape key and ⌘. both close the window.

Restorable Window Frame

The settings window autosaves its frame through UserDefaults, so the last position is restored automatically.

Supported for Renamed “Settings”

Before macOS Ventura, “Settings” was called “Preferences”. This module supports both names.

More details of this design (Japanese): macOS Venturaからの新しい“Settings”表記と、旧“Preferences”表記からの移行

Pane Layout Helpers

Building the standard “label on the left, controls on the right” form by hand means writing the same constraints over and over. Two helpers are provided to avoid that, and both are supported.

The section-based layout is the recommended one. You add one section per row and it owns the column widths, the spacing and the wrapping of description text for you.

The guide-based layout is not deprecated. You write your own constraints against shared layout guides, which is what you want for a pane that does not fit the two-column form.

Both come with a wireframing feature for debugging. See Building a Pane Layout.

Section-based:

Guide-based:

Lazy Pane Loading

A pane is built the first time its tab is selected, not when the window is created, and a loading view stands in until it is ready. A tab nobody visits costs nothing.

Awaiting Content with async/await

Building lazily is not the same as building asynchronously. A pane that has to fetch something before it can be built overrides loadPaneContent() async throws and awaits it there. Leaving that pane mid-load cancels the task, and the tab is loaded again on the next visit.

See Loading Pane Content.

Structure

Class Role
SettingsWindowController Owns the window, restores its frame and the tab that was selected last
SettingsWindow The settings window itself: the close-only title bar, the zoom button, the resize animation
SettingsTabViewController The contentViewController. Owns the panes, drives the transitions, sizes the window to each pane
SettingsPaneViewController One per tab. Subclass it and build the content in buildPaneContent(), or in loadPaneContent() when it has to be awaited
SettingsTabLayoutView A nested NSTabView inside a single pane, with either layout on each tab. See NSTabView Inside a Pane
SettingsHostingView Hosts a SwiftUI view and measures its height against the width it is given. See Embedding SwiftUI Views
LayoutDebugWireframes The debugging overlay. The section-based layout is built on it, and it is public so you can use it on your own views. See Wireframes

A pane is built the first time its tab is selected. See Loading Pane Content. Its size is recorded during viewDidLoad() when a Storyboard or its own constraints laid it out, and from the build itself when it was built in code. See Resolving the Pane Size.

The demo app has one tab per thing worth showing. See the pane classes under Demo/Panes.

Tab What it shows Built with
General The two-column form at its plainest, with indented sub-options under a checkbox Section-based. addColumnSection, checkboxes, a pop-up button, description labels, separators
View The label-less sections next to the two-column ones, and controls that carry no intrinsic width Section-based. addButtonSection, addCheckboxSection, addCustomView with a color well and a slider
Extensions A pane that fetches before it can be built, and a table that takes the surplus height as the window is dragged Section-based with no declared item column width. loadPaneContent() async throws, a flexible-height section, isResizableView
Updates One pane divided into tabs, AppKit on one and SwiftUI on the other SettingsTabLayoutView, SettingsHostingView, a repeating SF Symbol effect
Advanced A pane laid out in a Storyboard rather than in code Guide-based. SettingsPaneLayoutGuide, capturePreferredPaneSize()
Developer A resizable pane that declares its own bounds Guide-based in code, with the width and height limits set by hand

Usage

There are two ways to give the settings window its panes.

A. Initialize SettingsWindowController with the panes as an array

// First, initialize the SettingsWindowController instance
let settingsWindowController = SettingsWindowController(with: [/*panes*/])

// …like this:
let settingsWindowController = SettingsWindowController(with: [
	SettingsPaneViewController(tabName: "General",
							   tabImage: NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil),
							   tabIdentifier: "general",
							   isResizableView: false),
	SettingsPaneViewController(tabName: "View",
							   tabImage: NSImage(systemSymbolName: "eyeglasses", accessibilityDescription: nil),
							   tabIdentifier: "view",
							   isResizableView: true),
	SettingsPaneViewController(tabName: "Extensions",
							   tabImage: NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: nil),
							   tabIdentifier: "extensions",
							   isResizableView: false),
	SettingsPaneViewController(tabName: "Advanced",
							   tabImage: NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: nil),
							   tabIdentifier: "advanced",
							   isResizableView: false),
])

// That’s all. Then you can show the settings window.
settingsWindowController.showWindow(nil)

B. Set panes to a SettingsTabViewController instance

SettingsTabViewController takes panes through its own set, add and insert methods. To remove a pane, use NSTabViewController’s own methods.

Building a Pane Layout

Section-Based Layout (recommended)

Section-based layout

Create a SettingsLayoutView, install it into the pane view, then add one section per row. install(in:margins:) pins the layout view to the pane view, with the system standard spacing on all four edges unless you pass .insets(_:) for your own margins.

class GeneralSettingsPaneViewController: SettingsPaneViewController {

	private var layoutView: SettingsLayoutView?

	override func loadView() {
		view = NSView()
	}

	override func buildPaneContent() {
		let layoutView = SettingsLayoutView()
		layoutView.install(in: view)
		self.layoutView = layoutView

		let startup = layoutView.addColumnSection(label: "Startup", identifier: .init("Startup"))
		startup.addCheckbox(title: "Open at Login", target: self, action: #selector(toggleItem(_:)))

		let downloads = layoutView.addColumnSection(label: "Save Downloads To", identifier: .init("Downloads"))
		let location = downloads.addPopUpButton(target: self, action: #selector(selectItem(_:)))
		location.addItems(withTitles: ["Downloads Folder", "Desktop", "Ask Each Time"])
		downloads.addDescriptionLabel("Choose where downloaded files are saved.")

		layoutView.addSeparatorSection()

		layoutView.addButtonSection(title: "Restore Defaults", target: self, action: #selector(restoreDefaults(_:)))
	}

}

Sections

Sections are stacked in the order you add them, spaced by SettingsLayoutMetrics.sectionSpacing. You never place them yourself.

addColumnSection builds the two-column row. The rest — separator, button, checkbox and custom — drop the label column, and the custom one takes any view you hand it.

Items added to a two-column section stack downward, spaced by SettingsLayoutMetrics.itemSpacing. The section vends checkboxes, push buttons and pop-up buttons ready-made, takes description text and arbitrary views, and can place an accessory view on the trailing side of an item you already added.

When adding an item you can choose how the label lines up with it: .firstBaseline (default), .top or .centerY. Use .centerY for controls that have no text baseline, such as a switch or a color well.

Indentation

Anything you add can be indented — the items of a two-column section, and the label-less sections as well. Pass indentationLevel: when adding it, and indentationPerLevel: if the step of SettingsLayoutMetrics.indentationPerLevel does not suit you. A separator takes no indentation, since it always spans the container.

Sub-options under a checkbox are the common case.

let download = layoutView.addColumnSection(label: "Download", identifier: .init("Download"))
download.addCheckbox(title: "Download in the background", target: self, action: #selector(toggleItem(_:)))
download.addCheckbox(title: "Notify when ready",
					 isOn: true,
					 indentationLevel: 1,
					 target: self,
					 action: #selector(toggleItem(_:)))

In a two-column section the inset counts towards the width the item column asks for, so an indented item is not squeezed for being indented. A description label wraps within whatever the column has left after the inset. SettingsSectionView.indentation(forLevel:perLevel:) is public if you want the same inset on a view you place yourself.

Checkboxes with indentation.

Section Width

A section without the label column decides the area it is laid out in with SettingsSectionWidthMode. .fullWidth (the default for the add…Section methods) spans the whole container, so the content reaches the pane margins. .contentBlock follows the two-column block instead, so the edges line up with the column sections.

The two only look different once the columns are narrower than the pane, because the block stays centered while the container does not. A separator divides the whole pane, so it always spans the container and takes no width mode.

layoutView.addButtonSection(title: "Restore Defaults",
							alignment: .trailing,
							widthMode: .contentBlock,
							target: self,
							action: #selector(restoreDefaults(_:)))

widthMode is also a property on SettingsSectionView, so a section can be switched over after it has been added, and a section you build yourself starts at .contentBlock.

Section Height

A section follows the height of its content by default. SettingsSectionHeightMode.flexible(minimumHeight:preferredHeight:) makes it take in the surplus height of the pane instead, which is what a table or a text view in a resizable pane needs.

let installed = layoutView.addColumnSection(label: "Installed",
											heightMode: .flexible(minimumHeight: 60, preferredHeight: 120),
											identifier: .init("Installed"))
installed.addStretchingCustomView(scrollView)

minimumHeight is the lower bound the section keeps, so it decides how short the window can be dragged. preferredHeight is the height the pane opens at, and it gives way once the window is dragged shorter.

When several sections are flexible, the surplus is shared equally between them. Their lower bounds stay independent, so each falls back to its own minimum when the window is at its shortest.

In a two-column section, add the stretching view with addStretchingCustomView(_:verticalAlignment:). It fills the item column and lines the label up with the top. A custom section stretches its content view on its own.

Vertical resizing also needs isResizableView on the pane. See Controlling Window Resizing Per Pane.

Column Widths

You do not set the column widths. The label column follows its longest label, and the item column follows whatever its items need — including the width a description label wants before it has to wrap. Whatever is left over becomes equal margins on both sides, so the whole block stays centered.

Three knobs are available, all of them unset by default: a maximum width for the item column, and a minimum for each of the two columns.

The item column maximum works in both directions despite its name. It caps the column, so a long description wraps instead of widening the pane, and it floors it as well, so the column keeps that width even when the items are narrower. Declaring it on any one addColumnSection call reaches the whole pane, because the item column is shared across every section. When several sections declare a width, the narrowest one wins — that is the only value all of them can satisfy at once.

Leaving a column unset lets it hug its content, at the cost of a pane that grows and shrinks with whatever is in it.

The label column minimum is worth reaching for when the labels are far shorter than the items. Without it the block is still centered, but it reads as left-heavy: the label column collapses while the item column does not, so the visible content drifts away from the middle of the pane.

The “Extensions” pane of the demo app combines the two. It declares no width for the item column, and floors the label column at 100.

The wireframes make the difference plain: the item column settles on its widest item, while the label column sits exactly on the floor rather than on its one-letter labels.

Reaching Sections Afterwards

Pass an identifier when adding a section if you want to find it later. SettingsLayoutView vends its sections as arrays, looks one up by identifier, and can move or swap them by identifier as well.

Resolving the Pane Size

SettingsTabViewController sizes the window from each pane’s preferredPaneSize. For a pane built in code, call sizePaneToFitContent(minimumWidth:) at the end of the build. It gives the view a lower bound, shrink-wraps it to the sections and records the result.

override func buildPaneContent() {
	buildSections()
	sizePaneToFitContent(minimumWidth: 450)
}

Description labels only learn the width they wrap at during layout, so the method measures repeatedly until the size stops changing. It is safe to call again with a different lower bound.

For a pane sized by a Storyboard or by your own constraints, call capturePreferredPaneSize() instead. It lays the view out and records the resulting frame size, without touching the width.

Re-measuring After a Change

preferredPaneSize is a snapshot, and SettingsTabViewController caches it again per tab. Neither notices a later change to the content, the font size or the locale.

Call invalidatePaneSize() on the pane to measure again and refresh both caches. The window resizes to follow only while that pane is the one on screen.

layoutView?.addColumnSection(label: "Sync", identifier: .init("Sync"))
invalidatePaneSize()

Guide-Based Layout (experimental)

Guide-based layout

Enable the container view, set the label column width if you need to, then constrain your own views against labelLayoutGuide and secondaryAreaLayoutGuide. Passing nil for maximumWidth lets the container grow freely.

class AdvancedSettingsPaneViewController: SettingsPaneViewController, SettingsPaneLayoutGuide {

	var contentContainerView: SettingsPaneContainerView?

	override func buildPaneContent() {
		setContentContainerView(maximumWidth: 550)
		contentContainerView?.labelLayoutGuideWidth = 160
		contentContainerView?.debug_setWireframes(true)
		capturePreferredPaneSize()
	}

}

NSTabView Inside a Pane

Secondary NSTabView

A pane with more content than fits one screen can put it behind tabs. SettingsTabLayoutView lays a nested NSTabView into the pane and hands each tab what it needs.

override func buildPaneContent() {
	let tabLayoutView = SettingsTabLayoutView()
	tabLayoutView.install(in: view)

	let general = tabLayoutView.addSectionTab(label: "General", identifier: "General")
	general.addColumnSection(label: "Schedule", identifier: .init("Schedule"))
		.addCheckbox(title: "Check automatically", isOn: true, target: self, action: #selector(toggleItem(_:)))

	tabLayoutView.addHostingTab(label: "Advanced", rootView: AdvancedSettingsView())

	sizePaneToFitContent(minimumWidth: 480)
}

addSectionTab returns the SettingsLayoutView laid into that tab, so a tab is filled the same way a whole pane is. addHostingTab wraps your SwiftUI view in a SettingsHostingView and returns it. addTab takes any view you built yourself. The tabView property is the NSTabView underneath, for whatever the three do not cover.

A nested NSTabView says nothing about its size on its own, so the container reports the size the largest tab needs. sizePaneToFitContent(minimumWidth:) then works here as it does anywhere else, and invalidateMeasuredSize() measures again after the content of a tab changed.

install(in:margins:) keeps the tabs closer to the toolbar than the system margin would, since the tab strip reads as part of the toolbar area. Pass .insets(_:) to set all four edges yourself.

The “Updates” tab of the demo app is built this way, with the section layout on one tab and SwiftUI on the other.

Embedding SwiftUI Views

SettingsHostingView in NSTabView

NSHostingView sizes itself from an unspecified proposal, so content whose height follows its width — text that wraps, most of all — comes out short. SettingsHostingView measures the height against the width instead. Hand it a root view and add it like any other view.

let section = layoutView.addColumnSection(label: "Notes", identifier: .init("Notes"))
section.addCustomView(SettingsHostingView(rootView: NotesView()))

It reports the height its content needs at the width it currently has, and asks again whenever that width changes. addHostingTab(label:rootView:) wraps your view in one of these for you. Call invalidateMeasuredSize() after changing the content from outside the SwiftUI state, and assign rootView to replace the content itself.

SwiftUI Controls of a Fixed Size

DemoSwitch

A switch or a stepper has one size and keeps it, so measuring against a width buys nothing. What it needs instead is for that size to resolve at all: NSHostingView resolves its SwiftUI layout only once it belongs to a window, and until then it ignores environment values such as controlSize and reports the default size. A pane is measured while it is being built, before it reaches a window, so that difference is left over as slack in the layout.

Settle the size at initialization by putting the hosting view into a window and running a single layout pass. The window is already reachable from the pane through tabViewController?.view.window.

final class DemoSwitch: NSHostingView<DemoSwitchView> {

	convenience init(sizingWindow: NSWindow?, onChange: @escaping (Bool) -> Void) {
		self.init(rootView: DemoSwitchView(onChange: onChange))
		settleIntrinsicContentSize(in: sizingWindow)
	}

	private func settleIntrinsicContentSize(in window: NSWindow?) {
		guard let contentView = window?.contentView else { return }

		contentView.addSubview(self)
		layoutSubtreeIfNeeded()
		removeFromSuperview()
	}

}

addSubview(_:) alone does not settle anything, the layout pass is what does. Once resolved the size sticks, even after the view leaves the window again. See DemoSwitch in Supports.swift of the demo app.

Avoid measuring the pane again after it appears. Resizing the window from viewDidAppear() competes with the window presentation and the tab transition.

Wireframes

layoutView.debug_setWireframes(true) reveals the layout. It has no effect outside a DEBUG build, and it also reaches sections added after the call.

Sections appear as blue hatching, the label and item columns as red and green areas, and the labels and controls inside them as purple outlines. Vertical rules mark the container edges and center, the block edges and the inner edge of each column, and the measured widths are printed along the top.

The rules run the whole height of the pane and past its margins, so a section whose edge is off shows up at a glance. The numbers carry a decimal, since Auto Layout hands out fractions and a hair of misalignment is worth seeing.

The drawing is done by LayoutDebugWireframes, which is public and tied to nothing in particular. You can register your own views and layout guides with it, as borders, fills, hatching, rules or width readouts.

let wireframes = LayoutDebugWireframes(host: someView)
wireframes.add(guide: someGuide, color: .systemRed)
wireframes.addRule(at: .maxX, of: someGuide, color: .systemRed)
wireframes.isEnabled = true

// From the host’s layout()
wireframes.updateLayout()

A view is bordered through its own layer, so Auto Layout keeps that one in place. Everything else is drawn by layers laid into the host, which is why updateLayout() has to run from the host’s layout(). Call refresh() after putting views inside an already registered view, and removeAll() to drop every registration.

isEnabled shows or hides everything at once, and assigning true does nothing outside a DEBUG build. LayoutDebugWireframeColor carries the colors the settings panes use; the line width and the alpha values are static properties on LayoutDebugWireframes itself.

Appearance of Tabs

A tab is described by properties on SettingsPaneViewController: tabName (an alias of NSViewController.title), tabImage for the icon, and tabIdentifier, which should be unique.

localizeKeyForTabName localizes the tab name automatically — SettingsTabViewController replaces tabName with the localized one, unless you turn that off with its disablesLocalizationWithTabNameLocalizeKey property. This is useful when view controllers are initialized in Interface Builder. Otherwise, prefer String(localized:) or NSLocalizedString() when assigning tabName.

Controlling Window Resizing Per Pane

Set isResizableView on SettingsPaneViewController to allow window resizing only while that pane is active. It defaults to false. See the demo implementation and the Main storyboard.

minimumPaneSize and maximumPaneSize bound the drag, and sizePaneToFitContent(minimumWidth:) fills in the minimum for you. Giving both the same width leaves a pane resizable in height alone.

SettingsTabViewController remembers the size the user left each resizable pane at, so leaving a tab and coming back restores it. The record lives only while the app runs; userResizedSize(for:) and setUserResizedSize(_:for:) are there if you want to persist it yourself.

Toolbar Minimum Width Clamping

SettingsTabViewController has a clampsToToolbarMinimumWidth property, enabled by default. It clamps every pane to at least the content width the toolbar layout requires, which prevents the flicker you would otherwise see when a pane prefers to be narrower than that.

Loading Pane Content

Lazy Loading

A pane is built the first time its tab is selected, not when the settings window is created. Keep loadView() to the bare view and build the content in buildPaneContent(), so a tab that is never visited costs nothing.

override func loadView() {
	view = NSView()
}

override func buildPaneContent() {
	buildSections()
	sizePaneToFitContent(minimumWidth: 450)
}

SettingsTabViewController drives the rest: the loading view stands in while the content is built, the window is resized once the pane has measured itself, and the pane is revealed with a dissolve. Call loadAllTabs() if you would rather have every pane built up front.

Awaiting the Content with async/await

Override loadPaneContent() when the content cannot be built until something has been awaited — a file read, a network call, an actor. It is isolated to the main actor, so the views are built right there without hopping threads.

override func loadPaneContent() async throws {
	let items = try await MyDataSource.load()

	buildSections(with: items)
	sizePaneToFitContent(minimumWidth: 450)
}

Its default implementation calls buildPaneContent(), so a pane with nothing to await never has to know this method exists.

Cancellation

Leaving a pane before its load has finished cancels the task behind it. The awaited call throws, the pane is left unloaded, and it is built again the next time the tab is visited. Nothing has to be written for that to happen, so let the error propagate rather than swallowing it.

Work with no suspension point of its own can check in between:

for batch in batches {
	try Task.checkCancellation()
	append(batch)
}

A resource that lives outside the task is torn down with withTaskCancellationHandler(operation:onCancel:).

Cancellation is cooperative, so a load can still run to completion after it was cancelled. The pane is left unloaded either way, which means buildPaneContent() and loadPaneContent() have to tolerate running twice — discard whatever the previous pass built before building again.

Failure

Throwing anything else counts as a failure. The pane is left unloaded, an empty view is shown in its place, and paneContentDidFailToLoad(_:) is called with the error the pane threw. The library does not wrap it, and it does not report cancellation there. Visiting the tab again loads the pane once more.

Presenting the failure is up to you. Whatever you put into the pane from there has to be cleared by the next build, since the pane is still considered unloaded.

License

See LICENSE for details.

About

A package for building settings / preferences UI in macOS AppKit-based apps.

Resources

Stars

62 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages