SwiftUI on macOS: Life Cycle and AppDelegate
This article has a source code supplement. To read that in parallel, open this link in a separate window or tab so you can follow this account with its full source code.
This is the first in what I hope will be a new series tackling how to write apps based on SwiftUI for macOS, rather than for multiple OS including macOS. Instead of providing Xcode projects to support the discussion, I’ve laid out relevant source in the supplementary article so you can choose how you want to read them, and can copy that code into your own projects.
Although Apple doesn’t claim that SwiftUI is a complete replacement for existing class libraries such as AppKit, when you create your first SwiftUI app its templates are set up without AppKit. This article looks at some of the problems that brings, and how to work around them. In particular, it concentrates on implementing events in the app life cycle.
AppDelegate roles
In AppKit, the AppDelegate (NSApplicationDelegate) is central to an app’s life cycle. Its applicationDidFinishLaunching function is typically used to perform initial setup, such as:
checking any required resources
checking for app updates
loading app preferences
configuring app-wide menus, such as custom Help commands
initialising any globally accessible objects
performing some actions, such as customising the app’s Dock menu, that can’t be performed elsewhere.
Similarly, AppDelegate’s applicationWillTerminate function is often used to make any final adjustments to saved app preferences, and dismantle any features that need to be removed before the app quits.
Note that in an AppKit app, it’s the AppDelegate class that is declared as the @main entry point for the code.
SwiftUI with AppDelegate
Although it’s possible to use an AppDelegate in SwiftUI, Apple unusually warns against that, stating in a box marked Important: “Manage an app’s life cycle events without using an app delegate whenever possible.” However, Apple gives few clues as to how that might be accomplished.
If you have to go against that advice, your existing AppDelegate only really requires removal of its @main entry point. If you’re building for macOS 14, you may find it useful to make your AppDelegate an ObservableObject so your interface will be able to respond to changes in it, but everything else there should work as expected.
You then need to modify the SwiftUIApp to declare that AppDelegate as a private variable, as shown in the source code.
The ContentView then declares the AppDelegate as an EnvironmentObject, and everything should work fine.
In this example, the AppDelegate initialises an object that runs time-consuming checks, including accessing a remote Property List. To ensure that those checks are complete, once they are, AppDelegate sets a state variable isInited. While that remains false, the ContentView shows a message stating that the app is waiting for results, or that could be a progress indicator. Once isInited becomes true, ContentView obtains key results from those checks and displays them.
The snag with this approach is that it’s often not possible for the ContentView to be previewed in Xcode, as it may encounter an error, although it works fine when the app is built and run. But this does allow a SwiftUI app to access features that can only be used in an AppDelegate.
Eliminating AppDelegate
In this case, AppDelegate doesn’t do anything that can’t be done elsewhere in a regular SwiftUI approach. Even if your app still requires AppDelegate, for instance to customise its Dock menu, it’s worth moving as much as you can out of AppDelegate.
Rather than AppDelegate initialising the global object Skinner, this is now performed in the SwiftUIApp by assigning it to a local variable.
In the ContentView, the state variable isInited is now a simple global variable, enabling this to preview properly in Xcode.
Much of the setup that was performed in the AppDelegate is now moved to the globally accessible Skinner object, and performed in its initialisation. Once those are complete, the init function runs the checks performed by Skinner; being Observable, those can then be updated in the ContentView as those checks complete.
Some features implemented in the AppDelegate will require separate class implementations, here the use of a Timer to repeat checks on a regular basis. These are set up in another Observable object whose sole purpose is to manage the Timer.
applicationWillTerminate
SwiftUI does maintain a record of its life cycle, in the ScenePhase. This has three states: active, inactive and background, and each has a different significance according to whether it’s the ScenePhase of a Scene or App. States given from a View or Scene represent the ScenePhase of that Scene, but don’t indicate that of the app’s life cycle. So a Scene can pass from background to active again.
However, the ScenePhase for the App represents the aggregate for all its scenes, and when that enters the background you can “expect the app to terminate soon after.” That transition can thus be used as an equivalent of AppDelegate’s applicationWillTerminate, and “you can use that opportunity to free any resources”.
Apple provides example code for detecting and responding to that state change, although that uses a now-deprecated form of onChange(). My example code uses one of the variants that still holds good for macOS 14.
Summary
If possible, AppDelegate should be avoided in SwiftUI apps for macOS.
AppDelegate will still be required for access to some functions not available elsewhere, though, in which case as much as can be moved out of AppDelegate should be transferred elsewhere.
Retaining an AppDelegate may prevent Xcode previews from working without error.
Initialisations can normally be performed in the initialisation of an Observable object, where necessary using a global state variable to signal when they’re complete.
Actions normally run just before app termination using AppDelegate’s applicationWillTerminate should be moved to occur when the App’s ScenePhase changes to background, soon after which the app will terminate.