Preferences and Defaults in Cocoa Apps

8 minute read

Keeping with my recent theme of Cocoa, here’s a little tutorial on how you can use Sindre Sorhus’s magnificent Preferences and Defaults packages to save the customizations your app’s users have made.

init

I’d rather not repeat myself, so I’m going to assume some knowledge of Interface Builder (IB) and Xcode in the following sections (or simply provide IB XML) to save your time and focus on the topic.

Loading the Packages

I’ll be using Carthage to install both these packages because I think it’s the most intuitive way to build external Swift code.

If you fundamentally disagree with that opinion, you probably know how to use CocoaPods or the Swift Package Manager in place of Carthage in the below steps:

  1. If you haven’t already, install Carthage via Homebrew like so: brew update && brew install carthage && carthage version. Before the final command exits it should output “0.33.0”, or maybe a higher number by the time you’re reading this, in which case 👋 hello from a written artifact from 2019!)
  2. Add a new “Empty” file to the project (a folder below the .xcodeproj, where the rest of the generated Swift code is in your new project) with the name “Cartfile” and no extension. Adding the Cartfile
  3. Add the following two lines to the Cartfile:
    github "sindresorhus/Defaults"
    github "sindresorhus/Preferences"
    
  4. Navigate to the directory with the Cartfile in the terminal app of your choice and run carthage update --platform macos. The platform argument saves you precious CPU cycles by skipping iOS, watchOS, and tvOS builds of the Defaults package, which will only be used in our macOS Cocoa app.
  5. Go back in Xcode and add the newly built frameworks to the project:
    1. click on the blue project icon at the top of the Project Navigator
    2. select the only target in the “Targets” subheading
    3. expand the “Frameworks, Libraries, and Embedded Content” disclosure triangle
    4. click the plus button at the bottom of the empty frameworks list
    5. click “Add Other…” and then “Add Files…” Finding the "Add Files..." button in the project target settings
    6. in the Finder modal, select the newly built .framework packages in Carthage > Build > Mac Selecting the frameworks to embed in the project
    7. click “Open” in the dialog. Final frameworks list

Making the Preferences Window

Using your favorite text editor, paste the following declaration into a file called “GeneralPreferencePane.xib” in the project’s root directory, and drag the new file from Finder to the Xcode project.

<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14810.11" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
    <dependencies>
        <deployment identifier="macosx"/>
        <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14810.11"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <objects>
        <customObject id="-2" userLabel="File's Owner" customClass="GeneralPreferenceViewController" customModule="Preferences_in_Cocoa" customModuleProvider="target">
            <connections>
                <outlet property="isCacheInvalidationHardCheckbox" destination="sEh-H5-f1d" id="cRZ-z6-keg"/>
                <outlet property="isNamingThingsHardCheckbox" destination="ATf-Be-SPn" id="1Af-1W-fG8"/>
                <outlet property="view" destination="c22-O7-iKe" id="ZQe-UH-LCX"/>
            </connections>
        </customObject>
        <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
        <customObject id="-3" userLabel="Application" customClass="NSObject"/>
        <customView misplaced="YES" translatesAutoresizingMaskIntoConstraints="NO" id="c22-O7-iKe">
            <rect key="frame" x="0.0" y="0.0" width="213" height="77"/>
            <subviews>
                <textField hidden="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" misplaced="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Xc1-oC-jbN">
                    <rect key="frame" x="598" y="-188" width="72" height="23"/>
                    <textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="General" id="kEa-Ts-PrR">
                        <font key="font" metaFont="system" size="20"/>
                        <color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
                        <color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
                    </textFieldCell>
                </textField>
                <button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ATf-Be-SPn">
                    <rect key="frame" x="18" y="18" width="173" height="18"/>
                    <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
                    <buttonCell key="cell" type="check" title="Naming things is hard" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="OSD-d1-F1D">
                        <behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
                        <font key="font" metaFont="system"/>
                    </buttonCell>
                    <connections>
                        <action selector="updateProblemPreferences:" target="-2" id="Rrp-Lj-XDT"/>
                    </connections>
                </button>
                <button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="sEh-H5-f1d">
                    <rect key="frame" x="18" y="41" width="177" height="18"/>
                    <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
                    <buttonCell key="cell" type="check" title="Cache invalidation is hard" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="GAi-bU-pW6">
                        <behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
                        <font key="font" metaFont="system"/>
                    </buttonCell>
                    <connections>
                        <action selector="updateProblemPreferences:" target="-2" id="YUY-Op-XrO"/>
                    </connections>
                </button>
            </subviews>
            <constraints>
                <constraint firstAttribute="bottom" secondItem="Xc1-oC-jbN" secondAttribute="bottom" constant="124" id="Ygh-kW-pw7"/>
                <constraint firstAttribute="trailing" secondItem="Xc1-oC-jbN" secondAttribute="trailing" constant="300" id="dPQ-fn-EVx"/>
                <constraint firstItem="Xc1-oC-jbN" firstAttribute="top" secondItem="c22-O7-iKe" secondAttribute="top" constant="124" id="obX-e4-hYJ"/>
                <constraint firstItem="Xc1-oC-jbN" firstAttribute="leading" secondItem="c22-O7-iKe" secondAttribute="leading" constant="300" id="sQd-RT-oXo"/>
            </constraints>
            <point key="canvasLocation" x="-171.5" y="56.5"/>
        </customView>
        <viewController id="8sz-EX-AFa"/>
    </objects>
</document>

In short, the XML above defines a single custom view with two appropriately-labeled (I’ve been waiting forever for an excuse to link that!) checkboxes:

The custom view in Interface Builder

It also defines its parent view controller as “GeneralPreferenceViewController”, some outlets to reference the checkboxes from code (more on that later), and an action to change the Defaults based on the element state.


Aside

It’s outside of the scope of this post, but for extra credit, try rebuilding this UI from scratch in Interface Builder!


Preferences + Defaults = ❤️

Make another file called AppPreferences.swift (this will have the Defaults keys, preference pane view controller).

We’ll add the following code to it.

import Cocoa
import Defaults
import Preferences

extension Defaults.Keys {
    static let isCacheInvalidationHard = Key<Bool>("isCacheInvalidationHard", default: true)
    static let isNamingThingsHard = Key<Bool>("isNamingThingsHard", default: true)

    static let observableDummyKey = Key<Bool>("dummyChanged", default: false)
}

extension PreferencePane.Identifier {
    static let general = Identifier("general")
}

final class GeneralPreferenceViewController: NSViewController, PreferencePane {
    let preferencePaneIdentifier = PreferencePane.Identifier.general
    let preferencePaneTitle = "General"
    
    override var nibName: NSNib.Name? {
        return "GeneralPreferenceViewController"
    }
    
    @IBOutlet var isCacheInvalidationHardCheckbox: NSButton!
    @IBOutlet var isNamingThingsHardCheckbox: NSButton!
    override func viewDidLoad() {
        super.viewDidLoad()
        if defaults[.isCacheInvalidationHard] {
            isCacheInvalidationHardCheckbox.state = .on
        } else {
            isCacheInvalidationHardCheckbox.state = .off
        }
        
        if defaults[.isNamingThingsHard] {
            isNamingThingsHardCheckbox.state = .on
        } else {
            isNamingThingsHardCheckbox.state = .off
        }
        
    }

    @IBAction func updateProblemPreferences(_: Any) {
        if isCacheInvalidationHardCheckbox.state == .on {
            defaults[.isCacheInvalidationHard] = true
        } else {
            defaults[.isCacheInvalidationHard] = false
        }
        
        if isNamingThingsHardCheckbox.state == .on {
            defaults[.isNamingThingsHard] = true
        } else {
            defaults[.isNamingThingsHard] = false
        }
        
        defaults[.observableDummyKey] = !defaults[.observableDummyKey]
 }
}

Breaking it down

Here’s a play-by-play breakdown of what’s going on here.

Let’s import Cocoa so that we can actually refer the stuff from our UI without a whole mess of “undeclared type” errors, as well as the two libraries that started this whole post:

import Cocoa
import Defaults
import Preferences

Here’s where Defaults comes in. We need to extend the Keys class with some keys of our own that can store the data represented by the checkboxes above, which have just two possible states, so of course booleans will fit this nicely.

extension Defaults.Keys {
    static let isCacheInvalidationHard = Key<Bool>("isCacheInvalidationHard", default: true)
    static let isNamingThingsHard = Key<Bool>("isNamingThingsHard", default: true)

    static let observableDummyKey = Key<Bool>("dummyChanged", default: false)
}

Here’s a generalized form for declaring these things inside the extension:

static let [preferenceObjectName] = Key<[preferenceType]>("[preferenceBackendDefaultsName]", default: [defaultPreferenceValue)

If the preference doesn’t need to be defined for the app to function (such as a preference dependent on another one being true), the OptionalKey type can be used in place of Key, and the default argument must be omitted.


Aside

In some personal projects I found I needed to declare a “dummy” key in addition to my main preferences:

static let observableDummyKey = Key<Bool>("dummyChanged", default: false)

Long story short, it’s a dirty hack but the cleanest way I know how to observe all the preference changes at once from the main app’s view controller. This works when you want to take action (such as refreshing data while honoring the changed defaults) on at least one of the changed keys:

class ViewController: NSViewController {
	var observer: DefaultsObservation?
	...
   	override func viewDidLoad() {
   		super.viewDidLoad()
		observer = defaults.observe(.observableDummyKey, options: [.old, .new]) { _ in
			self.reloadAfterPreferencesChanged()
		}
	}

The lack of the NSKeyValueObservingOptions.initial option in the [.old, .new] array passed to .observe is simply to prevent the observer’s handler from being called upon declaration.

Then, in an IBAction that’s called when any of the checkboxes are interacted with, observableDummyKey should be changed in some way. I simply negate the value like so:

defaults[.observableDummyKey] = !defaults[.observableDummyKey]

Now we need to set up our preference pane’s view controller, which will be called “General”.


Aside

“General” is usually the first pane in multi-pane macOS preference windows. Don’t believe me? Check the preferences of Safari, Terminal, Finder, and many more.

To add more preference panes you would simply follow all the steps that follow but wherever a specific pane (“General” in this post) is referred to, you would add your new one.


Give the preference pane an identifier, title, and toolbar icon (this only shows up if the PreferencesStyle is set to toolbarItems, and hidesToolbarForSingleItem is set to true in the PreferencesWindowController constructor or there is more than one preference pane):

extension PreferencePane.Identifier {
    static let general = Identifier("general")
}

final class GeneralPreferenceViewController: NSViewController, PreferencePane {
    let preferencePaneIdentifier = PreferencePane.Identifier.general
    let preferencePaneTitle = "General"
    let toolbarItemIcon = NSImage(named: NSImage.preferencesGeneralName)!

This view controller needs to override the nibName property to return the name of the .xib file “GeneralPreferencePane” we created in the “Making the Preferences Window” section.

    override var nibName: NSNib.Name? {
        return "GeneralPreferencePane"
    }

Outlet Connections

We need outlets to get the state of the checkboxes, so control-drag them from Interface Builder onto their corresponding lines:

    @IBOutlet var isCacheInvalidationHardCheckbox: NSButton!
    @IBOutlet var isNamingThingsHardCheckbox: NSButton!

After the view is loaded successfully, the default values can be accessed via the key properties we set in the Defaults.Keys extension, like so: defaults[.keyProperty].

This is used here to update the checkbox state based on the current Defaults boolean values.

    override func viewDidLoad() {
        super.viewDidLoad()

        if defaults[.isCacheInvalidationHard] {
            isCacheInvalidationHardCheckbox.state = .on
        } else {
            isCacheInvalidationHardCheckbox.state = .off
        }
        
        if defaults[.isNamingThingsHard] {
            isNamingThingsHardCheckbox.state = .on
        } else {
            isNamingThingsHardCheckbox.state = .off
        }
        
    }

Action Connections

When the checkbox state changes, we need to go the other way: updating the user defaults. Once again, control-drag both checkboxes to this IBAction, which translates their state to booleans for the default values.


    @IBAction func updateProblemPreferences(_: Any) {
        if isCacheInvalidationHardCheckbox.state == .on {
            defaults[.isCacheInvalidationHard] = true
        } else {
            defaults[.isCacheInvalidationHard] = false
        }
        
        if isNamingThingsHardCheckbox.state == .on {
            defaults[.isNamingThingsHard] = true
        } else {
            defaults[.isNamingThingsHard] = false
        }
    }

Putting it all together

Paste the following into AppDelegate.swift:

import Cocoa
import Preferences

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    @IBOutlet private var window: NSWindow!

    public lazy var preferencesWindowController = PreferencesWindowController(
        preferencePanes: [
            GeneralPreferenceViewController(),
        ],
        style: .segmentedControl,
        animated: true,
        hidesToolbarForSingleItem: true
    )

    @IBAction func preferencesMenuItemAction(_: NSMenuItem) {
        preferencesWindowController.show()
    }
}

This defines a toolbar-less (because there’s only one preference pane and hidesToolbarForSingleItem is true) PreferencesWindowController and shows it when a NSMenuItem connected to preferencesMenuItemAction is clicked.

Speaking of which, let’s set that menu item up now (or build and run the project and see how useless your work has been if you can’t access the preferences).

In the “Main Menu” section of Main.storyboard, click on the bolded project name and control-drag “Preferences…” to preferencesMenuItemAction in the AppDelegate.

Observing

From the rest of your code, you can now define an instance variable (called observer here) of the DefaultsObservation? type like so:

        observer = defaults.observe(.keyToObserve, options: [.old, .new]) { _ in
            methodToCallAfterKeyChanged()
        }

Code on,

Solomon

Sources and Further Reading