January 26, 2023

Making a Single-Window Mac App using SwiftUI

Traditionally, it was more work to write a Mac app with multiple windows. But with SwiftUI, it's the default. This is ideal for document-based apps, like Keynote for instance. It's not that great for an app that doesn't need multiple windows though.

While it's not immediately obvious, SwiftUI gives you a very simple way to set up a single-window mac app.

This tutorial is current as of XCode 14.2 and macOS Ventura (13.1).

example project on github : TLDR at the end of the post

The default with SwiftUI

When you create a new SwiftUI Mac App Project in XCode, the App module looks like this:

import SwiftUI

@main
struct MyGreatNewAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

The Window Menu looks like this:

picture of WIndow menu showing the name of the one window

The File Menu in your app looks like this:

picture of Main Menu with New Window menu item

If you choose New Window from the File Menu a few times, then the Window Menu looks like this:

picute of window menu with multiple copies of window

The View Menu looks like this:

picture of View menu with tab items

and if you choose to show tabs, you can have multiple tabs within a single window:

picute of a window with multiple tabs

If you close all those windows, your app doesn't quit. Instead, it stays running with no windows just in case you want to choose New Window again

picutre of desktop with now windows and menu bar

This behavior is completely appropriate if you want to write a document-based app, or a browser, or any kind of app that would need to provide multiple views of different kinds of data at the same time. Examples are Pages, Chrome and Terminal.

But a lot of apps don't need this kind of complexity. They only need to offer a single window, not all this multi-window behavior. Yet it might not be obvious how to go about removing it. Examples are System Settings, Messages and Twitter.

What we want

A well-designed single window app in macOS behaves slightly differently.

Its File Menu has no New Window menu item:

file menu with no new window item

Its Window Menu doesn't need to list windows because there's only ever one:

window menu no windows list

It has no option for multiple tabs in the View Menu.

view menu with no tabs options

and if you close its window, it doesn't stay open. It automatically quits as you would expect.

This is a much simpler user experience for an app that doesn't need to confuse the user with multiple windows.

The wrong way to do it

Okay, so we know what we want. But what's the best way to get it?

At first, you may want to modify the File Menu yourself, and force quit the app if the window closes, and then see what the next interaction is you want to modify, and so on. But you will continue to find small edge cases that don't work as you would expect, and your app may even be rejected from the app store for not following Apple's Human Interface Guidelines Luckily, there's a better way.

About Scene

Let's look back at that App module that you created with your new project.

WindowGroup {
    ContentView()
}

What's that WindowGroup thing? If you hold down the control and command keys while clicking on it in XCode, you'll be taken to its definition. Its interface is:

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
public struct WindowGroup<Content> : Scene where Content : View {

But what's that Scene type it's referring to?

A Scene's job is to manage the lifecycle of the app and present user interface elements that you provide in your Views. SwiftUI offers different kinds of Scene for different purposes. By default, your app uses a WindowGroup scene, which handles multiple windows and provides all the nice behaviors that a multi-window app needs.

But we don't need multiple windows. So let's do a search and see if there's a different kind of Scene that may work better for our needs.

Window to the Rescue

Wait, what's this?

@available(macOS 13.0, *)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public struct Window<Content> : Scene where Content : View {

It's a Scene that's designed to work only on macOS, and here's its documentation:

    /// A scene that presents its content in a single, unique window.

and reading a little further down:

/// A window can also be used as the main scene of your app, for when
/// the multi-window functionality isn't appropriate.

So if we use a Window instead of WindowGroup for our main scene, we'll get the single-window behavior for free.

@main
struct MyGreatNewAppApp: App {
    var body: some Scene {
        Window("My Great New App", id: "main") {
            ContentView()
        }
    }
}

The first parameter you pass in will appear as the title of your window.

window titled with title passed in

The second parameter, id, is used by SwiftUI to identify your window. It's required even though your app only has one window. I wouldn't recommend an empty string, but anything else should work for our purposes.

So how does it work

Running your app now, you can see that:

  • There's no window list in the Window Menu
  • There's no New Window menu item in the File Menu
  • Closing the window automatically quits the app.

But...

  • There's still a Show Tabs menu item in the View Menu

Getting Rid of the Tabs

Sadly, as of macOS Ventura (13.1), SwiftUI doesn't seem to handle removing the Show Tabs menu item from the View menu automatically. Luckily, SwiftUI is still running on top of AppKit, and it's a simple enough fix to handle this using AppKit. On the Mac, SwiftUI implements each window in its interface with a NSWindow instance. Each NSWindow has a property called tabbingMode which determines whether it allows tabs or not. By default, AppKit assumes that a window will allow tabs, but our Single-Window app doesn't want tabs. So we have to set the tabbingMode of our window to disallowed.

There are probably several ways to do this, but the simplest is probably to write our own Application Delegate. AppKit uses one class as the delegate for its app. The delegate is expected to handle app lifecycle events. SwiftUI doesn't require that you have an Application Delegate, but it can come in handy for cases like this.

All we need to do is tell the Application Delegate what to do when the application finishes launching with this method:

func applicationDidFinishLaunching(_ notification: Notification)    

(the notification parameter is a remnant of the old way AppKit used to do things. You can ignore it)

At the point when applicationDidFinishLaunching is called, the window is loaded, and we can access all loaded windows in the app using NSApplication's windows property.

NSApplication.shared.windows

So the full implementation of our Application Delegate is:

final class AppDelegate: NSObject, NSApplicationDelegate {

    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApplication.shared.windows.first?.tabbingMode = .disallowed
    }
}

and we can set up our app to use it with @NSApplicationDelegateAdaptor:

@main
struct SwiftUI_Mac_Single_Window_AppApp: App {

    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Window("A Single-Window App", id: "Single Window App") {
            ContentView()
        }
        .windowResizability(.contentSize)
    }
}

A better way to get rid of the tabs

January 28, 2023

...and as soon as I published this, I discovered a better approach. NSWindow has a static variable for toggling the ability to show tabs in windows: allowsAutomaticWindowTabbing. If you clear it once on the class, then any NSWindow that is created after that will not be able to have tabs, so the tabs menu items won't appear.

If your app will absolutely only have one window, then the above approach will work fine, but if you also want to have a Settings window, for example, then that window will also have the tabs menu items, unless you turn them off, and of course that window WON'T be visible at launch time.

So a more resilient applicationDidFinishLaunching would be:

func applicationDidFinishLaunching(_ notification: Notification) {
    NSWindow.allowsAutomaticWindowTabbing = false
}

TLDR

If you want to write a Single-Window mac app using SwiftUI, instead of writing:

import SwiftUI

@main
struct MyGreatNewAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

write:

final class AppDelegate: NSObject, NSApplicationDelegate {

    func applicationDidFinishLaunching(_ notification: Notification) {
        NSWindow.allowsAutomaticWindowTabbing = false
    }
}


@main
struct SwiftUI_Mac_Single_Window_AppApp: App {

    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Window("A Single-Window App", id: "any_string_will_do") {
            ContentView()
        }
        .windowResizability(.contentSize)
    }
}

oh, and also, it never hurts to go exploring in the documenatation. control-command-click is your best friend in XCode.

Tagged with: