{
"$type": "site.standard.document",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreihdwkjv4klgmqytvhmb4bgdgdvzctj3fiy66xtyrttrq7uvwfs46q"
},
"mimeType": "image/png",
"size": 147540
},
"description": "I spent a large part of last week learning and playing with SwiftUI. So far it’s been pretty frustrating at times since I kind of feel like I forgot everything I knew, but I’m also very excited about where this will lead us. I’ve summed up my first impressions in the “Thoughts on SwiftUI” post last week.\n\nAt the moment most of the available examples show how to use SwiftUI in iOS apps, but I wanted to see how it would work on the Mac (in AppKit), since it’s kind of closer to my heart (hint: look at the domain name :).\n\nAnd I had an idea: last year, when I was playing with the new Dark Mode in macOS Mojave I had a plan to build a simple app that would let you override the appearance in specific apps using the NSRequiresAquaSystemAppearance setting. I started working on it, but I got stuck while figuring out the complex NSTableView API which I had no experience with, and I gave up.\n\nSo, how about I give it another try now, but with SwiftUI? I don’t really need such app myself (I only use light mode), and I’m pretty sure I’ve seen other similar apps last year, but this seems like a perfect way to try out SwiftUI on the Mac - and to see if it will be easier to get the table view to work…\n\nUpdate 15.08.2020: I’ve updated all code to work with the latest (stable) version of SwiftUI, adapting it to all the changes that were made to the API in last year’s later betas.\n\n \n mackuba ∕ DarkModeSwitcher\n Simple app for overriding light mode per app on macOS (demo for a blog post)\n \n \n \n\nBuilding the app\n\nIn order to build a SwiftUI Mac app in Xcode, you need to have the beta version of macOS Catalina installed - SwiftUI will not work on Mojave. This is the first beta and some people have had serious issues with it, so be careful and don’t install it on your main computer. If you want to play with SwiftUI in UIKit, you can use this trick to make it run in a playground on Mojave, but it doesn’t work for AppKit.\n\nAnd one more thing to note - Apple has unfortunately removed any remaining ways to force the dark mode per app in one of the later Mojave betas. The only thing we can still do (so far) is force the light mode if the rest of the system uses dark mode.\n\nSo, let’s begin. First, we’re going to need a list of all installed apps. Let’s define an app model:\n\nclass AppModel {\n let name: String\n let bundleURL: URL\n var icon: NSImage?\n var bundleIdentifier: String?\n var requiresLightMode: Bool = false\n\n init(bundleURL: URL) {\n self.name = bundleURL.deletingPathExtension().lastPathComponent\n self.bundleURL = bundleURL\n }\n}\n\nWe’ll store the list of apps in a list manager called AppList. We’ll make it an ObservableObject so that the UI can subscribe to updates in apps, and we’ll use @Published to send updates whenever the app list changes:\n\nclass AppList: ObservableObject {\n @Published var apps: [AppModel] = []\n}\n\nThe AppScanner class will take care of finding and returning the inst…",
"path": "/2019/06/17/swiftui-appkit-dark-mode-switcher/",
"publishedAt": "2019-06-17T01:05:44Z",
"site": "at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/site.standard.publication/3mn5mackuba26",
"tags": [
"Cocoa",
"Mac",
"SwiftUI"
],
"textContent": "I spent a large part of last week learning and playing with SwiftUI. So far it’s been pretty frustrating at times since I kind of feel like I forgot everything I knew, but I’m also very excited about where this will lead us. I’ve summed up my first impressions in the “Thoughts on SwiftUI” post last week.\n\nAt the moment most of the available examples show how to use SwiftUI in iOS apps, but I wanted to see how it would work on the Mac (in AppKit), since it’s kind of closer to my heart (hint: look at the domain name :).\n\nAnd I had an idea: last year, when I was playing with the new Dark Mode in macOS Mojave I had a plan to build a simple app that would let you override the appearance in specific apps using the NSRequiresAquaSystemAppearance setting. I started working on it, but I got stuck while figuring out the complex NSTableView API which I had no experience with, and I gave up.\n\nSo, how about I give it another try now, but with SwiftUI? I don’t really need such app myself (I only use light mode), and I’m pretty sure I’ve seen other similar apps last year, but this seems like a perfect way to try out SwiftUI on the Mac - and to see if it will be easier to get the table view to work…\n\nUpdate 15.08.2020: I’ve updated all code to work with the latest (stable) version of SwiftUI, adapting it to all the changes that were made to the API in last year’s later betas.\n\n \n mackuba ∕ DarkModeSwitcher\n Simple app for overriding light mode per app on macOS (demo for a blog post)\n \n \n \n\nBuilding the app\n\nIn order to build a SwiftUI Mac app in Xcode, you need to have the beta version of macOS Catalina installed - SwiftUI will not work on Mojave. This is the first beta and some people have had serious issues with it, so be careful and don’t install it on your main computer. If you want to play with SwiftUI in UIKit, you can use this trick to make it run in a playground on Mojave, but it doesn’t work for AppKit.\n\nAnd one more thing to note - Apple has unfortunately removed any remaining ways to force the dark mode per app in one of the later Mojave betas. The only thing we can still do (so far) is force the light mode if the rest of the system uses dark mode.\n\nSo, let’s begin. First, we’re going to need a list of all installed apps. Let’s define an app model:\n\nclass AppModel {\n let name: String\n let bundleURL: URL\n var icon: NSImage?\n var bundleIdentifier: String?\n var requiresLightMode: Bool = false\n\n init(bundleURL: URL) {\n self.name = bundleURL.deletingPathExtension().lastPathComponent\n self.bundleURL = bundleURL\n }\n}\n\nWe’ll store the list of apps in a list manager called AppList. We’ll make it an ObservableObject so that the UI can subscribe to updates in apps, and we’ll use @Published to send updates whenever the app list changes:\n\nclass AppList: ObservableObject {\n @Published var apps: [AppModel] = []\n}\n\nThe AppScanner class will take care of finding and returning the installed apps:\n\nclass AppScanner {\n var applicationFolders: [URL] {\n return FileManager.default.urls(for: .applicationDirectory, in: .allDomainsMask)\n }\n\n func findApps() -> [AppModel] {\n var foundApps: [AppModel] = []\n let manager = FileManager.default\n\n for folder in applicationFolders {\n do {\n var isDirectory: ObjCBool = false\n let exists = manager.fileExists(atPath: folder.path, isDirectory: &isDirectory)\n\n guard exists && isDirectory.boolValue else { continue }\n\n let urls = try manager.contentsOfDirectory(\n at: folder,\n includingPropertiesForKeys: [],\n options: [.skipsHiddenFiles]\n )\n\n for url in urls {\n guard url.pathExtension == \"app\" else { continue }\n\n let app = AppModel(bundleURL: url)\n foundApps.append(app)\n }\n } catch {\n NSLog(\"Error: couldn't scan applications in %@\", \"\\(folder)\")\n }\n }\n\n return foundApps\n }\n}\n\nNotice that we manually add the /System/Applications path to the list - in macOS Catalina, built-in apps are now located on the read-only volume (!) and they’re put inside /System. Finder however does some magic tricks and displays them as if they were all in /Applications, so you don’t see the difference there, only in the terminal. FileManager.urls(for:in:) should of course find them too, but right now it doesn’t.\n\nThe findApps() method will be run asynchronously from AppList.loadApps:\n\nfunc loadApps() {\n DispatchQueue.global(qos: .userInitiated).async {\n let foundApps = AppScanner().findApps()\n let sortedApps = foundApps.sorted(by: { (app1, app2) -> Bool in\n return app1.name.localizedCaseInsensitiveCompare(app2.name) == .orderedAscending\n })\n\n DispatchQueue.main.async {\n self.apps = sortedApps\n }\n }\n}\n\nWe can now build a very simple SwiftUI list view that displays found apps:\n\nstruct ContentView: View {\n @ObservedObject var appList: AppList\n\n var body: some View {\n List(appList.apps, id: \\.bundleURL) { app in\n Text(app.name)\n }\n .frame(minWidth: 450, minHeight: 300)\n }\n}\n\nSince our ContentView requires an AppList, we need to pass it one in the preview too - we’ll just prepare a hardcoded list of a few apps:\n\nstruct ContentView_Previews: PreviewProvider {\n static var previews: some View {\n let names = [\"Firefox\", \"Pages\", \"Slack\", \"Twitter\"]\n let appList = AppList()\n appList.apps = names.map {\n AppModel(\n bundleURL: URL(string: \"/Applications/\\($0).app\")!\n )\n }\n\n return ContentView(appList: appList)\n }\n}\n\n(Pro tip: unless you’re working on a 20+ inch screen, you might struggle to fit both code and preview on the screen at the same time without longer code lines breaking - in that case, you may find the Cmd+Enter and Cmd+Alt+Enter shortcuts useful for switching the preview pane on and off.)\n\nFinally, we also need to update the AppDelegate to create the ContentView with an AppList (and tell it to load the apps). While we’re there, let’s also set the window’s title:\n\n// ... other window properties ...\n\nwindow.title = \"Dark Mode Switcher\"\n\nlet appList = AppList()\nappList.loadApps()\n\nwindow.contentView = NSHostingView(rootView: ContentView(appList: appList))\n\nWe should now get something like this:\n\nGreat 👍 It would be nice though to show app icons on the list, wouldn’t it? To do that, we’ll need to find and parse each app’s Info.plist.\n\nDisplaying icons\n\nTo decode the plist files, we’ll use PropertyListDecoder and a simple Codable struct:\n\nstruct AppInfo: Codable {\n let iconFileName: String?\n let bundleIdentifier: String\n\n enum CodingKeys: String, CodingKey {\n case iconFileName = \"CFBundleIconFile\"\n case bundleIdentifier = \"CFBundleIdentifier\"\n }\n}\n\nWe’ll also read the bundle identifiers, which we’ll later need to pass to defaults to read & write the NSRequiresAquaSystemAppearance setting.\n\nReading the Info.plists will be done in AppScanner’s processApp method:\n\nfunc processApp(app: AppModel) {\n let plist = app.bundleURL\n .appendingPathComponent(\"Contents\")\n .appendingPathComponent(\"Info.plist\")\n\n do {\n let contents = try Data(contentsOf: plist)\n let info = try PropertyListDecoder().decode(AppInfo.self, from: contents)\n\n DispatchQueue.main.async {\n app.objectWillChange.send()\n app.bundleIdentifier = info.bundleIdentifier\n\n if let iconFileName = info.iconFileName {\n let iconFile = app.bundleURL\n .appendingPathComponent(\"Contents\")\n .appendingPathComponent(\"Resources\")\n .appendingPathComponent(iconFileName)\n\n app.icon = iconFile.pathExtension.isEmpty ?\n NSImage(contentsOf: iconFile.appendingPathExtension(\"icns\")) :\n NSImage(contentsOf: iconFile)\n }\n }\n } catch let error {\n print(\"Could not load app info for \\(app.name): \\(error)\")\n }\n}\n\nWe’ll call this method asynchronously from findApps, so that we can quickly return the list of app names and then load the icons in the background:\n\nfor url in urls {\n // ...\n DispatchQueue.global(qos: .userInitiated).async {\n self.processApp(app: app)\n }\n}\n\nNotice the app.objectWillChange.send() at the beginning of processApp() - we need to somehow notify the UI that an app was updated. To do that, we’ll mark the AppModel as an ObservableObject just like the list and we’ll manually send notifications through the objectWillChange property (included automatically in ObservableObject) when the app is updated - then we’ll bind each app’s row view (which we’ll extract as a separate view) to that model.\n\nclass AppModel: ObservableObject {\n ...\n\nLet’s now update the UI to display the icons:\n\nprivate let iconSize: CGFloat = 32\n\nstruct ContentView: View {\n @ObservedObject var appList: AppList\n\n var body: some View {\n List(appList.apps, id: \\.bundleURL) { app in\n AppRowView(app: app)\n }\n .frame(minWidth: 450, minHeight: 300)\n }\n}\n\nstruct MissingAppIcon: View {\n var body: some View {\n Circle()\n .fill(Color.gray)\n .padding(.all, 2)\n .frame(width: iconSize, height: iconSize)\n .opacity(0.5)\n .overlay(Text(\"?\").foregroundColor(.white).opacity(0.8))\n }\n}\n\nstruct AppRowView: View {\n @ObservedObject var app: AppModel\n\n var body: some View {\n HStack {\n if app.icon != nil {\n Image(nsImage: app.icon!)\n .resizable()\n .frame(width: iconSize, height: iconSize)\n } else {\n MissingAppIcon()\n }\n\n Text(app.name)\n }\n }\n}\n\nFor the previews, we’ll add a couple of icons to the “Preview Assets” asset catalog and make the sample apps load them:\n\nstatic var previews: some View {\n let names = [\"Firefox\", \"Pages\", \"Slack\", \"Twitter\"]\n let appList = AppList()\n appList.apps = names.map {\n let app = AppModel(\n bundleURL: URL(string: \"/Applications/\\($0).app\")!\n )\n app.bundleIdentifier = \"app.\\($0)\"\n app.icon = NSImage(named: app.name.lowercased())\n return app\n }\n\n return ContentView(appList: appList)\n}\n\nNow it looks much better:\n\nLight mode switches\n\nThe last missing piece in the app rows are the switches that will show us the current light mode setting and let us change it. For that, we’ll need to make calls to the defaults command, passing it the app’s bundle identifier. We’re going to use John Sundell’s ShellOut micro-library to save us some work - luckily, adding Swift packages to Xcode projects is now super easy (from the File menu):\n\nWe’ll wrap the calls to the shell in a new Defaults class:\n\nprivate let RequiresAquaSetting = \"NSRequiresAquaSystemAppearance\"\n\nclass Defaults {\n func checkRequiresLightMode(for bundleIdentifier: String) -> Bool? {\n do {\n let arguments = [\"read\", bundleIdentifier, RequiresAquaSetting]\n let value = try shellOut(to: \"defaults\", arguments: arguments)\n\n return (value == \"1\")\n } catch {\n // ...\n }\n\n return nil\n }\n\n func setRequiresLightMode(_ lightMode: Bool, for bundleIdentifier: String) {\n do {\n let arguments = lightMode ?\n [\"write\", bundleIdentifier, RequiresAquaSetting, \"-bool\", \"true\"] :\n [\"delete\", bundleIdentifier, RequiresAquaSetting]\n\n try shellOut(to: \"defaults\", arguments: arguments)\n } catch let error {\n print(\"Error setting default for \\(bundleIdentifier): \\(error)\")\n }\n }\n}\n\nWe’ll check each app’s current setting in AppScanner.processApp:\n\nlet contents = try Data(contentsOf: plist)\nlet info = try PropertyListDecoder().decode(AppInfo.self, from: contents)\nlet defaultsSetting = Defaults().checkRequiresLightMode(for: info.bundleIdentifier)\n\nDispatchQueue.main.async {\n app.objectWillChange.send()\n\n // setting app.bundleIdentifier and app.icon ... \n\n if let defaultsSetting = defaultsSetting {\n app.requiresLightMode = defaultsSetting\n }\n}\n\nWe’re also going to need some kind of property in the AppModel to which we can bind the toggle’s selection, so that the setting is automatically saved with defaults write when the toggle is switched. We could probably bind it to requiresLightMode, but then its didSet would also be called during the initialization above, so let’s separate it into a second derived property:\n\nenum ModeSwitchSetting {\n case auto\n case light\n}\n\nvar modeSwitchSetting: ModeSwitchSetting {\n get {\n requiresLightMode ? .light : .auto\n }\n\n set {\n guard let bundleIdentifier = bundleIdentifier else {\n fatalError(\"No bundleIdentifier set\")\n }\n\n if newValue != modeSwitchSetting {\n requiresLightMode = (newValue == .light)\n\n DispatchQueue.global(qos: .userInitiated).async {\n Defaults().setRequiresLightMode(newValue == .light, for: bundleIdentifier)\n }\n }\n }\n}\n\nNow we can add the switches to the table view - we’ll use a segmented picker for that:\n\nvar body: some View {\n HStack {\n // ...\n\n Spacer()\n\n Picker(\"\", selection: $app.modeSwitchSetting) {\n Text(\"Auto\").tag(AppModel.ModeSwitchSetting.auto)\n Text(\"Light\").tag(AppModel.ModeSwitchSetting.light)\n }\n .pickerStyle(SegmentedPickerStyle())\n .disabled(app.bundleIdentifier == nil)\n .frame(width: 200)\n }\n}\n\nAnd voila! You should now be able to see which app has the light mode override enabled, toggle the setting for some apps and see it applied when the app is relaunched (at least for some apps, e.g. Calendar - probably not all apps will be willing to cooperate):\n\nMonitoring running apps\n\nThere’s one more thing we could add to improve the user experience - since it’s not obvious that you need to restart an app to notice any change, we could show a warning icon when you change the setting for an active app.\n\nLuckily, getting a list of active apps on macOS is very easy - we just need to call NSWorkspace.shared.runningApplications. The property can also be observed with KVO, and we’ll be immediately notified of changes when any app is launched or terminated (note: this only includes apps that appear in the dock).\n\nFirst, we need to add a couple of properties to AppModel. isRunning will store the running status, and needsRestart will be set whenever modeSwitchSetting is changed when the app is running, and cleared when it stops running.\n\n@Published var needsRestart: Bool = false\n\n@Published var isRunning: Bool = false {\n didSet {\n if !isRunning {\n needsRestart = false\n }\n }\n}\n\nvar requiresLightMode: Bool = false {\n didSet {\n if requiresLightMode != oldValue {\n needsRestart = isRunning\n }\n }\n}\n\nThe AppList will take care of observing the list of running apps and updating the status of each app:\n\nvar runningAppsObservation: NSKeyValueObservation?\n\nfunc startObservingRunningApps() {\n runningAppsObservation = NSWorkspace.shared.observe(\\.runningApplications) { _, _ in\n self.updateRunningApps()\n }\n\n updateRunningApps()\n}\n\nfunc updateRunningApps() {\n let runningApps = NSWorkspace.shared.runningApplications\n let runningIds = Set(runningApps.compactMap({ $0.bundleIdentifier }))\n\n for app in self.apps {\n if let bundleId = app.bundleIdentifier {\n app.isRunning = runningIds.contains(bundleId)\n }\n }\n}\n\nThe method startObservingRunningApps() should be called from loadApps(), right after we assign the apps property.\n\nWe can now show a warning icon in those rows where an app’s setting was changed, but the app was not relaunched after the change was made. The new SFSymbols icon set unfortunately isn’t available to AppKit apps yet; however, AppKit does have its own small set of system icons that were available basically since the beginning of time and haven’t been updated much since then. You can see them in the media library when you open it on a storyboard (but not in the code editor):\n\nLet’s use the “caution” icon - the icons are used by passing the name you can see in the list as the identifier to NSImage:\n\n// ...\nSpacer()\n\nif app.needsRestart {\n Image(nsImage: NSImage(named: \"NSCaution\")!)\n .resizable()\n .frame(width: 28, height: 28)\n .padding(.trailing, 5)\n .accessibility(\n label: Text(\"App requires restart\")\n )\n}\n\n// ...\n\nNow you should see the warning appear when you switch the toggle for a running app, and disappear when you quit the app:\n\nSearch bar\n\nOk, one last thing - how about a search bar for filtering apps at the top of the window? This one will be really simple, we can implement it completely in the SwiftUI view.\n\nFirst, we’ll add another component named SearchBar. Ideally we’d use some kind of search-styled text field, but there doesn’t seem to be one yet, so we’ll kind of fake it using an emoji and a custom clear button next to the field (we’ll use another built-in AppKit icon):\n\nstruct SearchBar: View {\n @Binding var query: String\n\n var clearIcon: NSImage {\n NSImage(named: \"NSStopProgressFreestandingTemplate\")!\n }\n\n var body: some View {\n HStack(spacing: 0) {\n Spacer()\n\n Text(\"🔍\")\n\n TextField(\"Search\", text: $query)\n .textFieldStyle(RoundedBorderTextFieldStyle())\n .padding(8)\n\n Button(action: clearQuery) {\n Image(nsImage: clearIcon)\n .opacity(query.count == 0 ? 0.5 : 1.0)\n }\n .disabled(query.count == 0)\n .padding(.trailing, 8)\n }\n .onExitCommand(\n // called when you press ESC\n perform: clearQuery\n )\n }\n\n func clearQuery() {\n self.query = \"\"\n }\n}\n\nThe query will be defined in the main view, because we’ll need it for filtering, and we’ll pass it to the search bar as a binding. The button on the right clears the entered query text, and is disabled (with a grayed out icon) if the field is empty.\n\nNow in the main view we define the query, wrap the list view in a VStack, add the search bar and a thin divider line below it, and instead of all apps, we’ll only return those that match the query:\n\nstruct ContentView: View {\n @ObservedObject var appList: AppList\n @State var query: String = \"\"\n\n var matchingApps: [AppModel] {\n if query.isEmpty {\n return appList.apps\n } else {\n return appList.apps.filter({\n $0.name.lowercased().contains(query.lowercased())\n })\n }\n }\n\n var body: some View {\n VStack(spacing: 0) {\n SearchBar(query: $query)\n\n Divider()\n\n List(matchingApps, id: \\.bundleURL) { app in\n AppRowView(app: app)\n }\n }\n .frame(minWidth: 450, minHeight: 300)\n }\n}\n\nOk, I lied - we need to change one small thing in AppDelegate too. When the NSWindow is created there, it’s passed a flag fullSizeContentView - which is basically the equivalent of that iOS 7 thing with content hiding below the navigation bar. If that flag is used, then unless the content at the top is scrollable, it will hide below the title bar and won’t be visible (that also includes the Text(\"Hello world\") from the template). It took me a while to figure out why the top of the view isn’t laid out correctly… I’ve filed a radar about this and I hope they’ll update this template in later betas, but for now you need to remove this flag from there.\n\nNow we can use the search bar to quickly find the app we’re interested in:\n\nAnd that’s it, our app is complete - it wasn’t that hard, was it? 😄 You can find the full code here (WTFPL): https://github.com/mackuba/DarkModeSwitcher.\n\nI suppose this might not be the best possible demonstration of using SwiftUI with AppKit, since the result doesn’t really look very… appkity, you could probably build a similarly looking list with Catalyst. Hopefully at least the segmented controls give it away. I was actually assuming the whole time that this wasn’t a proper NSTableView but rather some platform-independent SwiftUI list implementation, but it turns out it really is an NSTableView:\n\nTODO\n\nThere are definitely a few things that I’d like to do better, if I knew how:\n\nI had to hardcode a constant width for the segmented controls, ideally this should be dynamic depending on the locale - but without this, the controls actually had different widths depending on the app name, I wanted them to all have the same width (it’s possible that .alignmentGuide would help?)\nI’d like to show a tooltip when the mouse cursor hovers over the warning icon, to explain what this means\nI’d like to use some more Mac-specific NSTableView functionality like the alternating background colors for even & odd rows you can see e.g. in Finder\nthe search bar could look more like NSSearchField or UISearchBar\nthe check for if app.icon != nil is obviously ugly, but if let doesn’t work\nthe bindings code was generally all done in an “I have no idea what I’m doing” style, so that can probably be improved\nI’d like to disable the slide-in animation when the list rows are added and just make them all appear immediately\nI don’t know if there’s any way to add an “empty” image with no content that still takes the assigned space, like NSImageView(image: nil) (although the question mark icon looks better I think) (use Image(\"\") if you want that)\nI tried the .previewLayout modifier used in one of the tutorials to show a preview of a single AppRowView at a fixed full size, but it was always showing it very small with no spacer between the title and the switches\nfor some reason I’m getting a ton of AutoLayout constraint errors (yes, AutoLayout!) in the console about the vertical arrangement of the segmented controls, I have no idea what this is about\ndidChange.send() should not require this weird (()) thing (thanks @_Jordan for the tip) because they’ve used it as send() on the slides, but somehow it does\n\nAnd so on. Feel free to send me PRs, tweets and comments if you have any suggestions for improvements :)",
"title": "SwiftUI on AppKit: Building a Dark Mode switcher",
"updatedAt": "2025-08-20T01:05:55Z"
}