Today, I worked on the Linkage for Mac app. I had been running into an issue where changing a setting to track the open sidebar panel triggered a callback in the editor, notifying it of the setting change. This caused a crash when the editor view called a function in the sidebar to update the view, even after the sidebar view had been deinitialized. The problem was that I had forgotten to remove the observer detecting the setting change, so the deallocated sidebar was still being used. In other words, it was a bug that needed fixing. Once I resolved that, the original issue disappeared. However, this made me wonder if I could detect different setting changes. That’s the programming challenge I’ll discuss in this post.

There are no apparent ways to handle the problem of distinguishing between different types of UserDefaults settings changes. UserDefaults is a class provided in the Foundation framework, and it is used to save and retrieve persistent settings — they are saved in a file between app executions. The same settings are probably even shared across multiple instances of an app running, although running more than one instance of an app is not standard on a Mac. However, there are ways to save settings to a different file by initializing an instance of UserDefaults with a unique suite name. The suite name is just a name that becomes part of the file name, although the file naming, location, and other aspects of how UserDefaults saves data are irrelevant and could even change without affecting this code. Just know there is a way to create a set of UserDefaults separate from the “standard” user defaults.

I didn’t know this would work, so I had to work through numerous issues during this small project. The first step was to create an extension to the existing framework class and give it properties for the two different sets of defaults I wanted to save:

extension UserDefaults {
	// The standard defaults are used for visual preferences and the views get notified when they change.
	static let view = UserDefaults.standard
	// The behavior defaults are used for all non-visual preferences and no notifications are needed for these.
	// The behavior defaults are also used for storing some state information that also won't need notifications.
	static let behavior = UserDefaults( suiteName: "David-Rector.Linkage.behavior" ) ?? .standard
}

Once this was done, I could easily get and set values in each location. I then decided that instead of having one large set of constants, strings that had variable names so I didn’t have to spell out the correct settings name each time I used one, I should have a set of constants associated with each different set of user defaults. I went ahead and created structures for this (suing structs because I only ever use these statically and never instantiate them). I also created a protocol and implemented default implementations of some functions that are needed to make it easy to get and set various types of values. Here is the protocol:

protocol Preferences {
	static var defaults: UserDefaults { get }
	static func bool( forKey defaultName: String ) -> Bool?
	static func integer( forKey defaultName: String ) -> Int?
}

extension Preferences {
	static func bool( forKey key: String ) -> Bool? {
		let result = Self.defaults.object( forKey: key )
		return result == nil ? nil : result as? Bool
	}
	
	static func bool( forKey key: String ) -> Bool {
		let result = Self.defaults.object( forKey: key )
		return result == nil ? false : result as! Bool
	}
	
	static func integer( forKey key: String ) -> Int? {
		let result = Self.defaults.object( forKey: key )
		return result == nil ? nil : result as? Int
	}
	
	static func set(_ value: Any?, forKey key: String) {
		Self.defaults.setValue( value, forKey: key )
	}
}

Here are the two different structs that I used to get and set the various settings, along with the settings names set as static constants:

struct ViewPreferences: Preferences {
	static let defaults = UserDefaults.view
	
	static let showHints = "showHints"
	static let showGroundDimensions = "showGroundDimensions"
	static let drawingLayerDimensions = "drawingLayerDimensions"
	static let largeText = "largeText"
	static let highNumericPrecision = "highNumericPrecision"
	static let motionPathMarkings = "motionPathMarkings"
	static let hollowElements = "hollowElements"
	static let additionalMomentum = "additionalMomentum"
	static let printActualSize = "printActualSize"
	static let editDrawing = "editDrawing"
	static let editMechanism = "editMechanism"
	static let viewDrawing = "viewDrawing"
	static let viewMechanism = "viewMechanism"
	static let showLabels = "showLabels"
	static let showVideoArea = "showVideoArea"
	static let showDimensions = "showDimensions"
	static let showGrid = "showGrid"
	static let showPartsList = "showPartsList"
	static let circularDimensionType = "circularDimensionType"
	static let showDebug = "showDebug"
	static let showAllProperties = "showAllProperties"
	static let multipleMonitorConsistency = "multipleMonitorConsistency"

	static func initialize() {
		defaults.register( defaults: [
			Self.showHints: true,
			Self.showGroundDimensions: true,
			Self.drawingLayerDimensions: false,
			Self.largeText: false,
			Self.highNumericPrecision: false,
			Self.motionPathMarkings: false,
			Self.additionalMomentum: false,
			Self.printActualSize: false,
			Self.editDrawing: true,
			Self.editMechanism: true,
			Self.viewDrawing: true,
			Self.viewMechanism: true,
			Self.showLabels: true,
			Self.showVideoArea: false,
			Self.showDimensions: false,
			Self.showGrid: false,
			Self.showPartsList: false,
			Self.showAllProperties: false,
			Self.circularDimensionType: CircularDimension.radius.rawValue,
			Self.multipleMonitorConsistency: false,
		] )
	}
	
	static var showDebugValue = false
}

struct BehaviorPreferences: Preferences {
	static let defaults = UserDefaults.behavior

	static let infiniteGuidelines = "infiniteGuidelines"
	static let newLinksSolid = "newLinksSolid"
	static let deleteOrphanedAnchors = "deleteOrphanedAnchors"
	static let snapToAngles = "snapToAngles"
	static let snapToNodes = "snapToNodes"
	static let snapToElements = "snapToElements"
	static let snapToGrid = "snapToGrid"
	static let snapToGuidelines = "snapToGuidelines"
	static let snap = "snap"
	static let doAutoJoin = "doAutoJoin"
	static let moveLockedLinks = "moveLockedLinks"
	static let sidebarVisible = "sidebarVisible"
	static let sidebarSelection = "sidebarSelection"
	
	static func initialize() {
		defaults.register( defaults: [
			Self.newLinksSolid: false,
			Self.infiniteGuidelines: true,
			Self.deleteOrphanedAnchors: true,
			Self.snapToElements: true,
			Self.snapToAngles: true,
			Self.snapToNodes: true,
			Self.snapToGrid: true,
			Self.snapToGuidelines: true,
			Self.snap: true,
			Self.doAutoJoin: true,
		] )
	}
}

The code includes an initialize() function that will create and set the values the first time the app runs. The function is called each time the app starts, but values are only stored if they don’t already exist in the settings storage.

This worked pretty well so far. I changed all the code to use the two structs to get and set values, and nothing went wrong. But things didn’t go right either. The callback function for the settings change was getting called for any settings change. I was under the incorrect assumption, possibly confirmed by some incorrect information on the internet, that only the standard defaults caused this notification. Fortunately, this problem was easy to solve, and I just needed to check what defaults were getting changed. here’s the callback function with a fix to only act when a “view” setting has changed:

	@objc func userDefaultsDidChange( notification: Notification ) {
		let changedDefaults = notification.object as? UserDefaults
		if changedDefaults == nil || changedDefaults == UserDefaults.view {
			let useLargeFont = ViewPreferences.bool( forKey: ViewPreferences.largeText ) ?? false
			font = useLargeFont ? largeFont : smallFont
			precision = ( ViewPreferences.bool( forKey: ViewPreferences.highNumericPrecision ) ?? false ) ? Precision.high : Precision.low
			
			let editDrawing = ViewPreferences.bool( forKey: ViewPreferences.editDrawing ) ?? true
			let editMechanism = ViewPreferences.bool( forKey: ViewPreferences.editMechanism ) ?? true
			var layers = 0
			layers |= editMechanism ? Layer.mechanism.rawValue : 0
			layers |= editDrawing ? Layer.drawing.rawValue : 0
			document?.editLayers = layers

			updateView()
		}
	}

Notice that the actual object for the notification change is sent as part of the Notification parameter. I should also point out that the name or key for the setting is not sent (to the best of my knowledge), so this whole effort could not have been avoided by simply checking which setting was changing. I think this is a cleaner solution than checking the names of settings. So the entire problem is solved, as far as I knew. But as soon as I tried to run the Linkage app, I got exceptions when I opened the preferences/settings window.

The settings window has checkboxes and other controls that change the settings. I had worked on the code that managed the settings, but the old bindings I set up in Xcode (with the Interface Builder) were now all broken. The next step was to fix the bindings, and this was a tough task due to there not being a lot of documentation on the NSUserDefaultsController. I stumbled for a while, taking advice from ChatGPT that never worked right, until I finally got to the point of having my own user default controllers, one for each of my different storage locations. here’s the code for those:

class SuiteSpecificUserDefaultsController: NSObject {
	var userDefaults: UserDefaults { fatalError("Subclasses must override this property") }
	
	override init() {
		super.init()
	}
	
	// Custom getter for the defaults
	@objc dynamic var values: [String: Any] {
		return userDefaults.dictionaryRepresentation()
	}
	
	// Custom setter for the defaults
	@objc override dynamic func setValue(_ value: Any?, forKey key: String) {
		userDefaults.setValue(value, forKey: key)
	}
	
	// Getter to get the specific value for a key
	@objc override dynamic func value(forKey key: String) -> Any? {
		return userDefaults.object(forKey: key)
	}
}


class ViewUserDefaultsController: SuiteSpecificUserDefaultsController {
	override var userDefaults: UserDefaults {
		return UserDefaults.view
	}
}

class BehaviorUserDefaultsController: SuiteSpecificUserDefaultsController {
	override var userDefaults: UserDefaults {
		return UserDefaults.behavior
	}
}

I still needed to add an NSObject to the preferences window in Interface Builder and then assign it a custom class that matches one of these. After that, I set up bindings to these controllers, and things worked as expected.

I can now use settings bindings in Interface Builder while keeping view-altering settings separate from behavior settings. This is useful because I no longer update the editor view unless a setting change affects what you see in the view. This solution could be expanded to any number of separate suites of settings. I’ll still look into simplifying the code if possible, but I think this is the final solution, and it works great.

Update…

I just made a change, and it seems to work ok. The purpose of this change today is to make it impossible to pass a string into any functions for getting and setting preferences and to only allow a specific set of values from an enum. Note the change to the Preferences protocol to allow for the associatedtype so each of the Preferences variations can have its own set of enums. I still plan on changing the code so there is a flag in the two different Preferences structs to indicate if a notification is needed. Then the notification code won’t need to check for a specific variation – it will only need to convert the object to a “Preferences” type and then check the flag. Like with much of my code, I’m trying to make it forward compatible so if I added a third set of preferences/settings, other code would not need to change. here is the new code using enums for keys:

//
//  UserDefaults.swift
//  Linkage
//
//  Created by David Rector on 5/16/23.
//  Copyright © 2025 David Rector. All rights reserved.
//

import Foundation

extension UserDefaults {
	// The standard defaults are used for visual preferences and the views get notified when they change.
	static let view = UserDefaults.standard
	// The behavior defaults are used for all non-visual preferences and no notifications are needed for these.
	// The behavior defaults are also used for storing some state information that also won't need notifications.
	static let behavior = UserDefaults( suiteName: "David-Rector.Linkage.behavior" ) ?? .standard
}

protocol Preferences {
	associatedtype Keys: RawRepresentable where Keys.RawValue == String
	static var defaults: UserDefaults { get }
	static func bool( forKey key: Keys ) -> Bool?
	static func integer( forKey key: Keys ) -> Int?
	static func string( forKey key: Keys ) -> String?
}

extension Preferences {
	static func bool( forKey key: Keys ) -> Bool? {
		let result = Self.defaults.object( forKey: key .rawValue)
		return result == nil ? nil : result as? Bool
	}
	
	static func bool( forKey key: Keys ) -> Bool {
		let result = Self.defaults.object( forKey: key.rawValue )
		return result == nil ? false : result as! Bool
	}
	
	static func integer( forKey key: Keys ) -> Int? {
		let result = Self.defaults.object( forKey: key.rawValue )
		return result == nil ? nil : result as? Int
	}
	
	static func string( forKey key: Keys ) -> String? {
		let result = Self.defaults.object( forKey: key.rawValue )
		return result == nil ? nil : result as? String
	}
	
	static func set(_ value: Any?, forKey key: Keys) {
		Self.defaults.setValue( value, forKey: key.rawValue )
	}
}

struct ViewPreferences: Preferences {
	static let defaults = UserDefaults.view
	
	enum Keys: String {
		case showHints = "showHints"
		case showGroundDimensions = "showGroundDimensions"
		case drawingLayerDimensions = "drawingLayerDimensions"
		case largeText = "largeText"
		case highNumericPrecision = "highNumericPrecision"
		case motionPathMarkings = "motionPathMarkings"
		case hollowElements = "hollowElements"
		case printActualSize = "printActualSize"
		case editDrawing = "editDrawing"
		case editMechanism = "editMechanism"
		case viewDrawing = "viewDrawing"
		case viewMechanism = "viewMechanism"
		case showLabels = "showLabels"
		case showVideoArea = "showVideoArea"
		case showDimensions = "showDimensions"
		case showGrid = "showGrid"
		case showPartsList = "showPartsList"
		case circularDimensionType = "circularDimensionType"
		case showDebug = "showDebug"
		case showAllProperties = "showAllProperties"
		case multipleMonitorConsistency = "multipleMonitorConsistency"
	}
	
	static func initialize() {
		defaults.register( defaults: [
			Keys.showHints.rawValue: true,
			Keys.showGroundDimensions.rawValue: true,
			Keys.drawingLayerDimensions.rawValue: false,
			Keys.largeText.rawValue: false,
			Keys.highNumericPrecision.rawValue: false,
			Keys.motionPathMarkings.rawValue: false,
			Keys.printActualSize.rawValue: false,
			Keys.editDrawing.rawValue: true,
			Keys.editMechanism.rawValue: true,
			Keys.viewDrawing.rawValue: true,
			Keys.viewMechanism.rawValue: true,
			Keys.showLabels.rawValue: true,
			Keys.showVideoArea.rawValue: false,
			Keys.showDimensions.rawValue: false,
			Keys.showGrid.rawValue: false,
			Keys.showPartsList.rawValue: false,
			Keys.showAllProperties.rawValue: false,
			Keys.circularDimensionType.rawValue: CircularDimension.radius.rawValue,
			Keys.multipleMonitorConsistency.rawValue: false,
		] )
	}
	
	static var showDebugValue = false
}

struct BehaviorPreferences: Preferences {
	static let defaults = UserDefaults.behavior

	enum Keys: String {
		case infiniteGuidelines = "infiniteGuidelines"
		case newLinksSolid = "newLinksSolid"
		case deleteOrphanedAnchors = "deleteOrphanedAnchors"
		case snapToAngles = "snapToAngles"
		case snapToNodes = "snapToNodes"
		case snapToElements = "snapToElements"
		case snapToGrid = "snapToGrid"
		case snapToGuidelines = "snapToGuidelines"
		case snap = "snap"
		case doAutoJoin = "doAutoJoin"
		case moveLockedLinks = "moveLockedLinks"
		case sidebarVisible = "sidebarVisible"
		case sidebarSelection = "sidebarSelection"
		case additionalMomentum = "additionalMomentum"
	}

	static func initialize() {
		defaults.register( defaults: [
			Keys.newLinksSolid.rawValue: false,
			Keys.infiniteGuidelines.rawValue: true,
			Keys.deleteOrphanedAnchors.rawValue: true,
			Keys.snapToElements.rawValue: true,
			Keys.snapToAngles.rawValue: true,
			Keys.snapToNodes.rawValue: true,
			Keys.snapToGrid.rawValue: true,
			Keys.snapToGuidelines.rawValue: true,
			Keys.snap.rawValue: true,
			Keys.doAutoJoin.rawValue: true,
			Keys.additionalMomentum.rawValue: false,
		] )
	}
}

class SuiteSpecificUserDefaultsController: NSObject {
	var userDefaults: UserDefaults { fatalError("Subclasses must override this property") }
	
	override init() {
		super.init()
	}
	
	// Custom getter for the defaults
	@objc dynamic var values: [String: Any] {
		return userDefaults.dictionaryRepresentation()
	}
	
	// Custom setter for the defaults
	@objc override dynamic func setValue(_ value: Any?, forKey key: String) {
		userDefaults.setValue(value, forKey: key)
	}
	
	// Getter to get the specific value for a key
	@objc override dynamic func value(forKey key: String) -> Any? {
		return userDefaults.object(forKey: key)
	}
}


class ViewUserDefaultsController: SuiteSpecificUserDefaultsController {
	override var userDefaults: UserDefaults {
		return UserDefaults.view
	}
}

class BehaviorUserDefaultsController: SuiteSpecificUserDefaultsController {
	override var userDefaults: UserDefaults {
		return UserDefaults.behavior
	}
}