When your view contains data that has to be manually saved (to a file for example), it is critical to know if your app is terminated or the window is closed. I found that out the hard way by making multiple edits to a file and promptly losing them all when I closed the window without saving!
There are mechanisms built in to SwiftUI that appear to notify you of the closure of your window but, in my experience, they're unreliable and, in the case of .onDisappear(), they just don't plain work. We need a better plan.
To make our application more reliable, we need to know when the window closes and to ask the user whether we should save their data or not.
Notifications
Mac applications send out notifications about a lot of events and SwiftUI provides us with a way to receive these event notifications and process them. We want to get hold of two notifications;
- The notification when your window will close.
- The notification when the application will terminate.
The application termination message is very important to us. If the user closes the application, the window close notifications will not be sent out; the application terminates immediately. S, we need to trap both and SwiftUI provides us a node view modifier to do this called .onReceive.
System Notifications
The .onReceive modifier can get messy very quickly. The specification of the event name is rather verbose, so we need a way to streamline the code to keep our view clean. To help us, we define a file that contains the system notification definitions.
import Foundation
import SwiftUI
struct SysNotifications {
// System notifications that we want receive.
static var willClose: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)
}
static var willTerminate: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
}
}
That return value is what we would normally plug in to our .onReceive, so you can see why it's not the kind of thing you want to scatter throughout your views.
View Changes
Now we have the system notifications defined, it is very straight forward to get our view to handle them.
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
.onReceive(SysNotifications.willClose, perform: windowClosing)
.onReceive(SysNotifications.willTerminate, perform: appTerminating)
}
func windowClosing(value: NotificationCenter.Publisher.Output) {
print("Window Closing")
}
func appTerminating(value: NotificationCenter.Publisher.Output) {
print("Application terminating")
}
}
The two .onReceive modifiers trap the window closing and application terminating system messages. If you run this code, you are going to see some disconcerting messages in the console. When our window is closed, we get several messages printed, not just the single one for our window. You will also see that, if the application is terminated, the window close isn't sent.
The second problem is why we intercept the window close and the application terminate. It allows us to cover both bases and ensure that we know when our window is going away.
The first problem is a little more tricky. You have to understand, these are 'global' notifications and that there may be multiple windows being closed when our view is closed. What we need to know is which window is being closed and whether it is ours.
Window Number
There is another article on here that explains how to get our NSWindow reference. It takes us through the creation of a HostingWindowFinder control...
import SwiftUI
public struct HostingWindowFinder: NSViewRepresentable {
var callback: (NSWindow?) -> Void
public init(callback: @escaping (NSWindow?) -> Void) {
self.callback = callback
}
public func makeNSView(context: Self.Context) -> NSView {
let view = NSView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
public func updateNSView(_ nsView: NSView, context: Context) {}
}
This is going to give us a reference to the NSWindow associated with our view. Once we have that, we can limit our code to only process close notifications for our own window.
We can now extend our view to get and check for our window number.
struct ContentView: View {
@State private var windowNumber: Int = 0
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
HostingWindowFinder { window in
if let window = window {
self.windowNumber = window.windowNumber
}
}.frame(width: 0, height: 0)
}
.padding()
.onReceive(SysNotifications.willClose, perform: windowClosing)
.onReceive(SysNotifications.willTerminate, perform: appTerminating)
}
func windowClosing(value: NotificationCenter.Publisher.Output) {
guard let win = value.object as? NSWindow,
win.windowNumber == windowNumber else { return }
print("Window Closing")
}
func appTerminating(value: NotificationCenter.Publisher.Output) {
print("Application terminating")
}
}
So we have added an @State property to hold the window number which we get from the HostingWindowFinder callback. In the code that handles windowClosing we now check that we were passed a window and that the window number matches our window specifically. If you run the code this time, the window closing message will only be issued once per window. We now know when our window is being closed and why.
In my application, where I use this strategy, I open an NSAlert to ask the user whether they want to save the file or not. If they request a save, I get the view model to save the data.
Get The Code