There are times when you need to do things across some or all of the documents which are open in an app, and at first sight the solutions don’t look good.
In my case, what I wanted to do was populate a popup menu in each document’s window with the same items, drawn from individual documents, being the time period for which data are displayed, as two Dates. In traditional terms, I might have had a global variable within the app which holds the list, then have each document maintain its own menu from that variable.
In a standard document-based Swift app, you could do this by creating your own NSApplication class, but this is strongly discouraged. At the other extreme, you could create a custom delegate, but for a single popup menu that looks like overkill. Instead, Swift and macOS provide UserDefaults, an easy-access lightweight class for making and maintaining common values across an app.
There are added complications. The contents of the popup menu are determined by the file whose data is displayed in that window. Imagine that we have three windows open, two showing data from file A, and one from file B. The settings offered in the popup menu in the windows on A should be those for the two windows open on A, but not for that open on B. Likewise, the settings for B should not show those for the windows open on A.
UserDefaults handles settings like these as key-value pairs in a dictionary. Each document/window needs a unique key against which to keep these settings, which are in turn a dictionary containing the file name and the timestamps of the start and end of the period for which it is displaying data. I decided that the best unique key for the documents/windows would be a UUID, created when the Document’s ViewController loads:
class ViewController: NSViewController {
var theUUID = UUID()
//…
override func viewDidLoad() {
super.viewDidLoad()
//…
self.theUUID = UUID.init()
//…
}
When the ViewController changes its settings, it can then update those held for it in UserDefaults:
func insertDefaults(startDate: Date, endDate: Date) {
if (self.theSourceLogFile != "") {
We have opened a file, whose name is in theSourceLogFile
, so next get the standard instance of UserDefaults
let defaults = UserDefaults.standard
Convert the dates to time intervals since the reference date, so that they are Doubles, which are easier to handle than Dates
let theSt1 = startDate.timeIntervalSinceReferenceDate
let theEn1 = endDate.timeIntervalSinceReferenceDate
Turn these into the dictionary to be stored for the UUID key
let theDict = ["file": self.theSourceLogFile, "start": theSt1, "end": theEn1] as [String : Any]
And set them in our standard UserDefaults
let theUUIDStr = self.theUUID.uuidString
defaults.set(theDict, forKey: theUUIDStr)
} }
To retrieve an array of strings containing the menu items for that popup menu from UserDefaults:
func getPossibleTimes() -> [String] {
var theStr: [String] = []
var theDict: [String: Any] = [:]
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZZZ"
Get the standard UserDefaults instance
let defaults = UserDefaults.standard
Then get its complete contents as a dictionary
theDict = defaults.dictionaryRepresentation()
if !(theDict.isEmpty) {
Iterate through each of the dictionary keys, testing whether it is a UUID, as there are other entries in its dictionary for window size and position, etc.
for theD1 in theDict.keys {
if let testUUID = UUID.init(uuidString: theD1) {
If it is, get its file name
let theD2 = theDict[theD1] as! [String : Any]
let theD3 = theD2["file"] as! String
If the file name matches this ViewController’s, get the values stored there
if (theD3 == self.theSourceLogFile) {
let theD4 = theD2["start"] as! Double
let theD5 = theD2["end"] as! Double
Convert the Doubles back to Dates
let theStDate = Date.init(timeIntervalSinceReferenceDate: theD4)
let theEnDate = Date.init(timeIntervalSinceReferenceDate: theD5)
Then convert them into strings suitable to make the entries in the popup menu
let theDate1Str = dateFormatter.string(from: theStDate)
let theDate2Str = dateFormatter.string(from: theEnDate)
let theDate3Str = theDate1Str + " - " + theDate2Str
Append that string to the result array, and when finished, return that array to use in the popup menu
theStr.append(theDate3Str)
} } } }
return theStr
}
Because we use UUIDs as keys, there is no point in letting these get saved automatically to the app’s preference file, which is normal behaviour for UserDefaults. So when the app is about to quit, remove all the settings which use UUIDs as keys, by adding the following to the AppDelegate class:
func applicationWillTerminate(_ aNotification: Notification) {
var theDict: [String: Any] = [:]
let defaults = UserDefaults.standard
theDict = defaults.dictionaryRepresentation()
if !(theDict.isEmpty) {
for theD1 in theDict.keys {
if let testUUID = UUID.init(uuidString: theD1) {
defaults.removeObject(forKey: theD1)
} } } }
That all works very well, but when you change the popup menu setting in one window, that doesn’t get synchronised across other windows on the same file. We need a notification system to tell each ViewController when the settings have been changed, and the popup menu needs to be rebuilt for that window.
Apple provides that in NotificationCenter, another class for which each app has a default instance. When each ViewController loads its view, we need to add that as an observer for a named notification. Thus, the viewDidLoad
function becomes
override func viewDidLoad() {
super.viewDidLoad()
//…
self.theUUID = UUID.init()
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(loadMenu), name: Notification.Name("Period-Changed"), object: nil)
}
We then provide a function which can be called by macOS, being specially marked so that it can be called by Objective-C code:
@objc func loadMenu() {
loadPeriodMenu()
}
That in turn removes the items from the popup menu, adds the refreshed list of items, and selects the correct item within that menu.
The function which triggers this, by posting the notification, is insertDefaults()
, which becomes:
func insertDefaults(startDate: Date, endDate: Date) {
if (self.theSourceLogFile != "") {
let defaults = UserDefaults.standard
let theSt1 = startDate.timeIntervalSinceReferenceDate
let theEn1 = endDate.timeIntervalSinceReferenceDate
let theDict = ["file": self.theSourceLogFile, "start": theSt1, "end": theEn1] as [String : Any]
let theUUIDStr = self.theUUID.uuidString
defaults.set(theDict, forKey: theUUIDStr)
let nc = NotificationCenter.default
nc.post(name: Notification.Name("Period-Changed"), object: nil)
} }
So each time that the start and end datestamps of a window are changed, each ViewController is informed via the default instance of NotificationCenter, and rebuilds its own popup menu contents.
In relatively few lines of fairly straightforward code, Woodpile has now got what it needs to perform magic with this popup menu. No NSApplications were harmed in the process.