A package for make easier implementing a structure of settings / preferences UI for macOS AppKit-based apps.
The window has preferences-style toolbar and native switching animation. It also supports “Reduce Motion” feature of accessibility.
Set active pane name as a window title automatically when panes are switched.
Display window title with pane name on the Window menu automatically.
Basically, the window only has a close button, but a zoom button is optional for per-pane.
We can use the Escape key ⎋ or ⌘. action to close the window.
The settings Window supports autosave frame via UserDefaults. The last window position can be restored automatically.
On before macOS Ventura, “Settings” was “Preferences”. This module can also support renamed “Settings” after Ventura.
More details of this design (Japanese): macOS Venturaからの新しい“Settings”表記と、旧“Preferences”表記からの移行
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.
| Approach | What you write | Suited for |
|---|---|---|
| Section-based (recommended) | One call per row | The standard two-column settings form |
| Guide-based | Your own constraints against shared guides | Layouts that do not fit the two-column form |
The guide-based layout is not deprecated, but the section-based approach is considerably simpler for ordinary settings panes because it owns the column widths, the spacing and the wrapping of description text for you.
Both come with a wireframing feature for debugging. See Building a Pane Layout.
Section-based:
Guide-based:
Window for Settings window.
WindowController for Settings window.
WindowController’s contentViewController. It manages tab transitions with a lazy loading architecture — pane content is loaded on demand when a tab is first selected, rather than all at once. A loadingView is displayed during transitions. You can show a loading label by setting showsLoadingLabel to true and customize its text with loadingLabelText.
If you prefer eager loading of all tabs, call loadAllTabs() explicitly.
The base view controller for setting pane. You can use this class to customize your own.
Override loadPaneContent(completion:) to perform asynchronous content loading before the pane is displayed. The isPaneContentLoaded flag is managed automatically by SettingsTabViewController.
Each pane captures its preferredPaneSize automatically in viewDidLoad(). If your subclass adds subviews or modifies constraints after super.viewDidLoad(), call capturePreferredPaneSize() at the end of your viewDidLoad() to recapture the correct size. See Resolving the Pane Size.
The container of the section-based layout. Stack sections into it and it decides the column widths, the spacing between sections and the width at which description text wraps. See Building a Pane Layout.
Please check the “General”, “View” and “Extensions” tabs on the demo app, and the matching view controllers in DemoViewControllers.swift. The “Extensions” pane declares no width for the item column, so it shows what a column does when left to hug its content.
The base class of every section. It exposes a contentGuide holding the section content, and a widthMode deciding whether that box follows the two-column block or spans the whole container. See Section Width.
A two-column section: one trailing-aligned label, and any number of leading-aligned items stacked downward.
SettingsSeparatorSectionView / SettingsButtonSectionView / SettingsCheckboxSectionView / SettingsCustomSectionView
Sections without the label column. They span the whole container by default, and all but the separator can line up with the two-column block instead.
The description label used by sections. It shrinks to its text and wraps only when the text does not fit the column.
This is a convenient container view for the Guide-based layout. It is disabled by default; if you wish to use it, first enable it using the setContentContainerView(maximumWidth:labelLayoutGuideWidth:) method of the SettingsPaneLayoutGuide. Then add any contents to this container view.
Please check the “Developer” tab on the demo app, DeveloperSettingsPaneViewController in DemoViewControllers.swift and SettingsPaneContainerView.
A protocol for the Guide-based layout, backed by SettingsPaneContainerView.
A debugging utility that outlines views and layout guides, draws rules across its host and prints measured widths. The section-based layout is built on it, and it is public so you can use it on your own views. See Wireframes.
Use SwiftPM.
To set panes of settings window, there are two ways of them.
// 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)func set(panes: [SettingsPaneViewController])
func add(panes: [SettingsPaneViewController])
func insert(panes: [SettingsPaneViewController], at index: Int)
func insert(tabViewItem: NSTabViewItem, at index: Int)To remove any pane, use NSTabViewController’s methods.
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()
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 are stacked in the order you add them, spaced by SettingsLayoutMetrics.sectionSpacing. You never place them yourself.
| Method | Result |
|---|---|
addColumnSection(label:itemColumnMaximumWidth:identifier:) |
Two-column section |
addSeparatorSection(identifier:) |
Horizontal separator, always spanning the container |
addButtonSection(title:controlSize:alignment:widthMode:identifier:target:action:) |
A single button, without the label column |
addCheckboxSection(title:isOn:description:widthMode:identifier:target:action:) |
A leading-aligned checkbox with an optional description, without the label column |
addCustomSection(_:widthMode:identifier:) |
Any view, without the label column |
A section without the label column decides the area it is laid out in with SettingsSectionWidthMode.
| Case | Result |
|---|---|
.fullWidth (default) |
The section spans the whole container, so its content reaches the pane margins |
.contentBlock |
The section follows the two-column block, so its 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(_:)))The default above is the one the add…Section methods pass. 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.
Items added to the same section stack downward, spaced by SettingsLayoutMetrics.itemSpacing.
| Method | Result |
|---|---|
addCheckbox(title:isOn:target:action:) |
Checkbox |
addButton(title:controlSize:target:action:) |
Push button |
addPopUpButton(controlSize:target:action:) |
Pop-up button |
addDescriptionLabel(_:) |
Supplementary description text |
addCustomView(_:verticalAlignment:) |
Any view |
addAccessoryView(_:to:spacing:) |
Any view, placed on the trailing side of an item you already added |
verticalAlignment decides how the label lines up with the first item: .firstBaseline (default), .top or .centerY. Use .centerY for controls that have no text baseline, such as a switch or a color well.
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.
| Knob | Effect |
|---|---|
itemColumnMaximumWidth |
Declares the width of the item column |
SettingsLayoutView.labelColumnMinimumWidth |
Floors the label column |
SettingsLayoutView.itemColumnMinimumWidth |
Floors the item column |
itemColumnMaximumWidth 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. It is also available as a property on SettingsColumnSectionView.
Leaving a column unset lets it hug its content, at the cost of a pane that grows and shrinks with whatever is in it.
labelColumnMinimumWidth 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.
SettingsTabViewController sizes the window from each pane’s preferredPaneSize. For a pane built in code, call sizePaneToFitContent(minimumWidth:) at the end of loadView(). It gives the view a lower bound, shrink-wraps it to the sections and records the result.
override func loadView() {
view = NSView()
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.
NSHostingView resolves its SwiftUI layout only once it belongs to a window. Until then it ignores environment values such as controlSize and reports the default size instead. A pane is measured during loadView(), before it reaches a window, so that difference is left over as slack at the bottom of 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 during loadView() 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.
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()Pass an identifier when adding a section if you want to find it later.
var sections: [SettingsSectionView]
var columnSections: [SettingsColumnSectionView]
func section(with identifier: NSUserInterfaceItemIdentifier) -> SettingsSectionView?
func columnSection(with identifier: NSUserInterfaceItemIdentifier) -> SettingsColumnSectionView?
func moveSection(_ identifier: NSUserInterfaceItemIdentifier, to index: Int)
func swapSections(_ first: NSUserInterfaceItemIdentifier, _ second: NSUserInterfaceItemIdentifier)layoutView.debug_setWireframes(true) reveals the layout. It has no effect outside a DEBUG build, and it also reaches sections added after the call.
| What appears | Meaning |
|---|---|
| Blue hatching | Section |
| Red and green areas | Label column, item column |
| Purple outlines | Labels and controls |
| Vertical rules | Container edges and center, block edges, label column trailing, item column leading |
| Numbers along the top | Measured widths of the container, the block and both columns |
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.
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()| Registration | Result |
|---|---|
add(view:color:descendantColor:) |
Border along the view. Passing descendantColor outlines the views inside it too |
add(guide:color:) |
Border along the layout guide |
addFill(view:color:) / addFill(guide:color:) |
Tint over the area |
addHatch(view:color:) |
Diagonal lines over the area |
addRule(at:of:color:) |
Line spanning the host at one edge of a view or a guide |
addWidthReadout(of:alignedTo:color:) |
Measured width printed above a view or a guide |
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().
| Member | Result |
|---|---|
isEnabled |
Shows or hides everything at once. Assigning true does nothing outside a DEBUG build |
ruleOverhang |
How far the rules run past the host bounds, letting them reach the edges of the view the host sits in |
refresh() |
Draws again. Call after views were put inside an already registered view |
removeAll() |
Drops every registration and clears what was drawn |
LayoutDebugWireframeColor carries the colors the settings panes use. The line width and the alpha values are static properties on LayoutDebugWireframes itself.
Enable the container view, set the label column width if you need to, then constrain your own views against labelLayoutGuide and secondaryAreaLayoutGuide. Setting maximumWidth to nil lets the container behave flexibly.
class AdvancedSettingsPaneViewController: SettingsPaneViewController, SettingsPaneLayoutGuide {
var contentContainerView: SettingsPaneContainerView?
override func viewDidLoad() {
super.viewDidLoad()
setContentContainerView(maximumWidth: 550)
contentContainerView?.labelLayoutGuideWidth = 160
contentContainerView?.debug_setWireframes(true)
capturePreferredPaneSize()
}
}There are properties of tab item in SettingsPaneViewController.
Default tab name, alias of NSViewController.title.
Icon for a tab.
Should set to a unique name.
This key is used for localizing tab name process automatically.
If you use this, SettingsTabViewController replaces tabName with the localized tab name. You can disable this feature with using a property disablesLocalizationWithTabNameLocalizeKey on SettingsTabViewController.
This is useful when initializing view controllers in Interface Builder, but in normal cases, use String(localized: “ANY KEY”) (or NSLocalizedString()) for tabName property.
SettingsPaneViewController has the property isResizableView; setting true to allow window resizing only while the pane is active. The default value is false. Check the Demo implementation and Main Storyboard file.
SettingsTabViewController has a clampsToToolbarMinimumWidth property (default: true). When enabled, all pane widths are clamped to at least the minimum content width imposed by the toolbar layout. This prevents visual flicker when a pane's preferred width is narrower than the toolbar requires.
See LICENSE for details.









