Issue #789

Using WindowGroup

New in SwiftUI 2.0 for iOS 14 and macOS 11.0 is WindwGroup which is used in App protocol. WindowGroup is ideal for document based applications where you can open multiple windows for different content or files. For example if you’re developing a text editor or drawing apps, you can show multiple windows for different text file or drawing. All is handled automatically for you if you use WindowGroup

An added benefit is you can use Settings and commands to add Preferences panel and menu at easy. These only works when you use WindowGroup

@main
struct MacApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("Custom Menu") {
                Button("Say Hello") {
                    print("Hello onmyway133")
                }
                .keyboardShortcut("h")
            }
        }

        Settings {
            VStack {
                Text("My Settingsview")
            }
        }
    }
}

You have these menu for free

  • Mac -> Preferences to show preferences panel with default Cmd+command shortcut
  • File -> New Window to open new Window
  • View -> Show Tab Bar to show tab bar with add button to open and manage tabs

External events

Another modifier that works exclusively for WindowGroup is handlesExternalEvents

Specifies a modifier to indicate if this Scene can be used when creating a new Scene for the received External Event. This modifier is only supported for WindowGroup Scene types.

Here you can declare another WindowGroup to handle external events separately

@main
struct MacApp: App {
    var body: some Scene {
        WindowGroup {
            MainView()
        }
        
        WindowGroup {
            HelperView()
        }
        .handlesExternalEvents(matching: Set(arrayLiteral: eventValues))
    }
}

Customize WindowGroup

WindowGroup is very convenient, but you may find it tricky to customize its behavior

Single window mode

To avoid user opening multiple windows at once, we can replace the default New Window and its keyboard shortcut altogether by using CommandGroup with replacing

@main
struct SingleWindowApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }.commands {
            CommandGroup(replacing: .newItem, addition: { })
        }
    }
}

Access underlying NSWindow

A common trick is to use an NSViewRepresentable to get underlying view.window and attach this to our SwiftUI view

struct WindowAccessor: NSViewRepresentable {
    @Binding var window: NSWindow?
    
    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async {
            self.window = view.window
        }
        return view
    }
    
    func updateNSView(_ nsView: NSView, context: Context) {}
}
@main
struct MacApp: App {
    @State
    private var window: NSWindow?

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .background(WindowAccessor(window: $window))
}

Disable tab

To disable tabbin, we can declare NSApplicationDelegateAdaptor to use our old NSApplicationDelegate again. When the app launches, set NSWindow.allowsAutomaticWindowTabbing to false to avoid

import AppKit
import SwiftUI

final class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        NSWindow.allowsAutomaticWindowTabbing = false
    }
}

@main
struct MacApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self)
    var appDelegate
}

Make window resizable

Instead of specifying width and height, use minWidth and minHeight to make window resizable

@main
struct AlmightyApp: SwiftUI.App {
    var body: some Scene {
        WindowGroup {
            MainView(store: Store.shared)
                .frame(minWidth: 800, minHeight: 600)
        }
    }
}

Search field on a toolbar

SwiftUI makes it easy to declare toolbar and items. While there is less options for placement on Mac than there is in iOS or Catalyst, the .navigation placement works OK

import SwiftUI
import EasySwiftUI
import Omnia

struct MainView: View {
    var body: some View {
        NavigationView {
            list
                .navigationTitle("Almighty")
                .toolbar {
                    ToolbarItem(placement: .navigation) {
                        Button(action: onToggleSidebar) {
                            Image(systemName: SFSymbol.sidebarLeft.rawValue)
                        }
                    }
                }
            content
            .toolbar {
                SearchField(text: $searchText)
                    .frame(width: 200)
            }
        }
    }
}

Here I use a simple NSSearchField to have the look and field of a native AppKit search field. This looks better than the plain SwiftUI TextField.

struct SearchField : NSViewRepresentable {
    @Binding
    var text: String

    func makeNSView(context: Context) -> NSSearchField {
        let view = NSSearchField()

        return view
    }

    func updateNSView(_ view: NSSearchField, context: Context) {
        view.stringValue = text
    }
}

Show settings programmatically

For now use can select menu MacApp -> Preferences or press Cmd comma to open settings view. In AppKit, the default preferences selector is showPreferencesWindow so you can trigger it like this

NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)

Use NSWindow for more control

With WindowGroup we gets lots of benefits, but it requires few tweaks to make it work our way, especially for single window scenario.

For a common macOS SwiftUI app with a search bar on the toolbar and a normal settings screen, here’s how I usually do

Setup NSWindow programmatically

Declare NSWindow, set a toolbarStyle and make it makeKeyAndOrderFront

let contentView = ContentView(
    store: stoore
)

let window = MainWindow(
    contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
    styleMask: [.titled, .closable, .miniaturizable, .resizable],
    backing: .buffered,
    defer: false
)
window.center()
window.setFrameAutosaveName("Almighty.MainWindow")
window.title = "Almighty"
window.toolbarStyle = .unifiedCompact
window.isReleasedWhenClosed = false

toolbar.delegate = self
window.toolbar = toolbar

window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
self.window = window

The cool thing is we can specify our own NSWindow subclass to do our own logic. Here is how I check for close and keyDown events. I prefer to do key handling at the window level instead of messing around with first responder between focused views and text fields.

import AppKit
import Omnia

final class MainWindow: NSWindow {
    private let debouncer = Debouncer(delay: 2)

    override func close() {
        orderBack(nil)

        debouncer.run {
            PreferenceManager.shared.save()
        }
    }

    override func keyDown(with event: NSEvent) {
        if !EventService.shared.inspect(event) {
            super.keyDown(with: event)
        }
    }
}

Add search field to toolbar

There’s no search bar in SwiftUI for macOS, only TextField. The native macOS search bar has a default magnifying glass icon and a clear button, which will take us some time to replicate it in pure SwiftUI. Or we need to use NSViewRepresentable to wrap NSSearchField

Example is from PastePal

To make tool bar items work, we need to implement NSToolbarDelegate and provides correct NSToolbarItem and specify which identifiers are allowed

extension NSToolbarItem.Identifier {
    static let searchItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("SearchItem")
}

extension AppDelegate: NSToolbarDelegate {
    func toolbar(
        _ toolbar: NSToolbar,
        itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
        willBeInsertedIntoToolbar flag: Bool
    ) -> NSToolbarItem? {
        switch itemIdentifier {
        case .searchItem:
            searchItem.searchField.delegate = self
            return searchItem
        default:
            return nil
        }
    }

    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        [
            .flexibleSpace,
            .searchItem
        ]
    }

    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        [
            .flexibleSpace,
            .searchItem
        ]
    }
}

Conclusion

In this article you’ve learned about the new WindowGroup and its benefit. You also learn how to customize it to some extends and how to use the AppKit NSWindow for more fine-grained control. SwiftUI is easy to declare but we have to accept some of its default behaviors, while managing NSWindow ourselves makes customization a breeze. Which you will choose depends on your app requirements.