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.