Streaming is available in most browsers,
and in the Developer app.
-
Bring multiple windows to your SwiftUI app
Discover the latest SwiftUI APIs to help you present windows within your app's scenes. We'll explore how scene types like MenuBarExtra can help you easily build more kinds of apps using SwiftUI. We'll also show you how to use modifiers that customize the presentation and behavior of your app windows to make even better macOS apps.
Resources
- Bringing multiple windows to your SwiftUI app
- DocumentGroup
- MenuBarExtra
- NewDocumentAction
- OpenDocumentAction
- OpenWindowAction
- Value and Reference Types
- Window
- WindowGroup
Related Videos
WWDC22
-
Download
♪ Mellow instrumental hip-hip music ♪ ♪ Hi, everyone.
I'm Jeff, an engineer on the SwiftUI team.
Today, I'm excited to talk to you about bringing multiple windows to your SwiftUI app on iPadOS and macOS.
In this session, we'll open with an overview of the various scene types in the SwiftUI lifecycle, including a few new types we're introducing.
Followed by showing how these scene types can be composed together, by adding auxiliary scenes.
Then we'll cover some new APIs for opening windows for a particular scene in your app.
And we'll wrap things up with a few ways for customizing an app's scenes.
Let's start with an overview of the existing scene types before digging in to some new ones.
You'll recall from previous sessions that apps in SwiftUI are composed of scenes and views.
Scenes commonly represent their contents with a window onscreen.
For example, here is an app I've built to keep track of the books I'm reading.
It's defined as a single window group which shows my reading list in a platform-appropriate way.
On platforms which support multiple windows, such as iPadOS and macOS, a scene can represent itself with several such windows.
The behaviors and representation of a scene vary based on the type used.
For example, a scene may only represent itself with a single instance, regardless of platform capabilities.
Let's take a look at the current list of scene types in SwiftUI.
WindowGroup provides a way to build data-driven applications across all of Apple's platforms.
DocumentGroup lets you build document-based apps on iOS and macOS.
And Settings defines an interface for representing in-app settings values on macOS.
These scene types can be composed together to extend your app's functionality.
We're extending the list of scenes with two new additions.
The first of which is Window, a scene which represents a single, unique window on all platforms; as well as a new scene type for macOS: MenuBarExtra, which renders as a persistent control in the system menu bar.
As with the other scene types, you can use Window and MenuBarExtra both as a standalone scene, or composed with other scenes in your app.
Unlike WindowGroup, the Window scene will only ever represent its contents in a single, unique window instance.
This characteristic can be useful when the contents of your scene represents some global app state that would not necessarily fit well with WindowGroups' multi-window presentation style on macOS and iPadOS.
For example, a game may wish to only allow for a single main window to render its contents.
MenuBarExtra is a new macOS-only scene type which behaves a little differently from our other scenes.
Rather than rendering its contents in a window, it will place its label in the menu bar and show its contents in either a menu or window which is anchored to the label.
Additionally, it will be useable as long as its associated app is running, regardless of whether that app is frontmost.
MenuBarExtra is great for creating standalone utility apps that provide easy access to their functionality.
Or it can be composed with other scenes to provide an alternate way to access your app's functionality.
It also supports two rendering styles: the default style, which shows the contents in a menu which pulls down from the menu bar, as well as a style that presents its contents in a chromeless window anchored to the menu bar.
With the addition of these two new scene types, SwiftUI apps can represent an even richer set of functionality across all of our platforms.
Let's see how these new APIs can be used in conjunction with our existing scene types.
Here's the definition of my BookClub app that I showed earlier.
It currently consists of a single window group.
On macOS, my BookClub app could benefit from an additional window to display our reading activity over time.
This is a great example of how macOS apps can make use of the additional screen real estate and flexible windowing arrangements present on that platform.
We'll add an auxiliary scene to our app for representing this interface.
Our Activity window's data is derived from our overall app state, so a window scene is the ideal choice for it.
Opening multiple windows with the same state would not fit well with our design.
The title provided to our scene will be used as the label for a menu item which is added to a section of the Window menu.
When selecting this item, the scene's window will be opened if not already so.
Otherwise, it will be brought to the front.
Now that we've covered adding an auxiliary scene to our BookClub app, I'd like to discuss some of the new scene presentation APIs we're adding and how you can integrate them into your app to provide richer experiences.
Our BookClub app has a context menu that can be invoked for any book in our Content List pane.
This context menu will include a button for triggering our window presentation.
We'll fill in the details shortly.
SwiftUI provides several new callable types via the environment for presenting windows tied to the scenes your app defines.
The first of these is openWindow action, which can present windows for either a WindowGroup or window scene.
The identifier passed to the action must match an identifier for a scene defined in your app.
openWindow action can also take a presentation value, which the presented scene will use to display its contents.
This form of the action is only supported by WindowGroup, using a new initializer that we'll take a look at shortly.
The type of the value must match against the type provided to the scene's initializer.
There are also two callable types in the environment for presenting document windows: newDocument action, which supports opening new document windows for both FileDocuments and ReferenceFileDocuments.
This action requires that the corresponding DocumentGroup in your app is defined with an editor role.
The document provided to this action will be created each time the window is presented.
For presenting document windows where the contents are provided by an existing file on disk, there is the openDocument action.
This action takes a URL to the file you wish to open.
Your app must define a DocumentGroup for presenting the window, and the document type for that group must allow for reading the type of the file at the provided URL.
Revisiting our button, we'll add the openWindow environment property to our view.
Since this type is a callable, we can just call it directly from our button's action.
Our Book type conforms to identifiable, so we'll pass its identifier as the value to present.
Now, before we move on, I'd like to discuss the values passed to the openWindow action.
I noted that I'm passing the book's identifier, which is a value of the UUID type.
In general, you'll want to prefer to use your model's identifier like this, rather than the value itself.
Note that our Book type is a value type.
As such, if we were to use it as the presented value, our new window would get a copy of the one which originated the presentation.
Any edits to either one will not affect the other.
Using the book's identifier lets our model store be the source of truth for these values instead by providing multiple bindings to a single value.
For more info on value type semantics, please see the developer documentation.
The type being presented must also conform to both the Hashable and Codable protocols.
Hashable conformance is needed to associate the presented value with an open window; while Codable conformance is required in order to persist the presented value for state restoration.
I'll discuss both of these behaviors in more detail shortly.
Lastly, when possible, prefer passing lightweight values.
Our book's identifier is another great example of this.
Since the value will be persisted by SwiftUI for state restoration, using smaller values will result in greater responsiveness of your app.
Now, our button now has the necessary pieces to present our detail windows, but nothing will show when it is selected.
This is because we've told SwiftUI to present a window for a certain data type, but haven't defined a scene in our app that reflects that.
Let's head back to our app and make that change now.
Alongside our primary WindowGroup and auxiliary window, we'll add an additional WindowGroup for handling our book details.
Our book details WindowGroup uses a new initializer.
In addition to the title, we're noting that this group presents data for the Book.ID type -- in our case, UUIDs.
This type should match the value that we are passing to the openWindow action we added earlier.
When a given value is provided to the WindowGroup for presentation, SwiftUI will create a new child scene for that value, and the root content of that scene's window will be defined by that value, using the group's view builder.
Each unique presented value will create a new scene.
The value's equality will be used to determine if a new window should be created or if an existing window can be reused.
When openWindow presents a value for which a window already exists, the group will use that window rather than creating a new one.
Using our BookClub app as an example, selecting the context menu action for a book which has already been presented in a window will result in that window being ordered front, rather than a second window showing the same book.
The presented value will also be automatically persisted by SwiftUI for the purposes of state restoration.
Your view will be given a binding to the initial presented value.
This binding can be modified at any time while the window is open.
When the scene is recreated for state restoration, SwiftUI will pass the most recent value to the window's Content view.
Here, we're giving the Book.ID binding to our detail view, which can look up the specified item in our model store for display.
With all our pieces in place, we can now select our context menu item and view our book details in its own window.
Lastly, I'd like to go over some of the ways in which you can customize the scenes in your app.
Because we've defined our app with two WindowGroup scenes -- one for the main viewer window and one for our detail windows -- SwiftUI by default will add a menu item for each group in the File menu.
The menu item for our detail window doesn't quite fit our use case, however.
I'd prefer that the windows can only be opened via the context menu that was added earlier.
A new scene modifier, commandsRemoved, allows you to modify a scene such that it will no longer provide its default commands, like the one in the File menu.
After applying this modifier, our File menu now only contains an item for opening windows for the primary WindowGroup.
I'm not quite happy with the current presentation of the auxiliary window scene for showing my reading activity, so let's focus on that next.
Since I'm going to apply a few modifiers to it, I'll extract it out into a custom scene, which will keep my app definition cleaner.
Absent any previous state for a window, SwiftUI will by default place it in the center of the screen.
I'd prefer it if the Reading Activity was placed in a different location by default, however.
By adding the new defaultPosition modifier, I can specify a position to be used when no previous state is available.
This position is relative to the screen size and will place the window in the appropriate location taking into account the current locale.
This new position helps differentiate my Activity window from the other viewing windows on the screen.
I'd also like my Activity window to show at a certain size by default, but still be resizable.
Alongside the defaultPosition, I'll add the defaultSize modifier.
The value provided to it will be given to the layout system to derive an initial size for the window.
Now that I've customized the presentation of my window, let's add one more modifier to customize its behavior.
The keyboardShortcut modifier has been expanded to work on scene types as well.
When used at the scene level, this modifier affects the command which creates a new window.
Here, I've modified my Activity window so that it can be opened with the shortcut Option-Command-0.
This can be a great way to customize your app by providing shortcuts to commonly used scenes and can also be used to customize the default shortcut of Command-N, which is added to the primary WindowGroup in your app.
This closes our tour of the new scene and windowing functionality in SwiftUI.
We're really excited about the potential of these new APIs and hope you are too! For more great info on how to add functionality to your iPadOS and macOS apps, please check out these other sessions: "SwiftUI on iPad: Organize your interface" and "SwiftUI on iPad: Add toolbars, titles, and more." Thanks for watching.
♪
-
-
2:01 - Scene composition
import SwiftUI import UniformTypeIdentifiers @main struct MultiSceneApp: App { var body: some Scene { WindowGroup { ContentView() } #if os(iOS) || os(macOS) DocumentGroup(viewing: CustomImageDocument.self) { file in ImageViewer(file.document) } #endif #if os(macOS) Settings { SettingsView() } #endif } } struct ContentView: View { var body: some View { Text("Content") } } struct ImageViewer: View { var document: CustomImageDocument init(_ document: CustomImageDocument) { self.document = document } var body: some View { Text("Image") } } struct SettingsView: View { var body: some View { Text("Settings") } } struct CustomImageDocument: FileDocument { var data: Data static var readableContentTypes: [UTType] { [UTType.image] } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } self.data = data } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } }
-
2:34 - Adding a window scene
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } class ReadingListStore: ObservableObject { }
-
3:01 - Standalone menu bar extra app
import SwiftUI @main struct UtilityApp: App { var body: some Scene { MenuBarExtra("Utility App", systemImage: "hammer") { AppMenu() } } } struct AppMenu: View { var body: some View { Text("App Menu Item") } }
-
3:35 - Windowed app with menu bar extra
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } #if os(macOS) MenuBarExtra("Book Club", systemImage: "book") { AppMenu() } #endif } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct AppMenu: View { var body: some View { Text("App Menu Item") } } class ReadingListStore: ObservableObject { }
-
3:42 - Menu bar extra with default style
import SwiftUI @main struct UtilityApp: App { var body: some Scene { MenuBarExtra("Utility App", systemImage: "hammer") { AppMenu() } } } struct AppMenu: View { var body: some View { Text("App Menu Item") } }
-
3:49 - Menu bar extra with window style
import SwiftUI @main struct UtilityApp: App { var body: some Scene { MenuBarExtra("Time Tracker", systemImage: "rectangle.stack.fill") { TimeTrackerChart() } .menuBarExtraStyle(.window) } } struct TimeTrackerChart: View { var body: some View { Text("Time Tracker Chart") } }
-
4:14 - Book Club app definition
import SwiftUI @main struct BookClubApp: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } class ReadingListStore: ObservableObject { }
-
4:38 - Adding an auxiliary Window Scene
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } class ReadingListStore: ObservableObject { }
-
5:28 - Open book context menu button
import SwiftUI struct OpenBookButton: View { var book: Book var body: some View { Button("Open In New Window") { } } } struct Book: Identifiable { var id: UUID }
-
5:34 - Opening a window using an identifier
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } } } struct OpenWindowButton: View { @Environment(\.openWindow) private var openWindow var body: some View { Button("Open Activity Window") { openWindow(id: "activity") } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } class ReadingListStore: ObservableObject { }
-
5:57 - Opening a window using a presented value
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } WindowGroup("Book Details", for: Book.ID.self) { $bookId in BookDetail(id: $bookId, store: store) } } } struct OpenWindowButton: View { var book: Book @Environment(\.openWindow) private var openWindow var body: some View { Button("Open In New Window") { openWindow(value: book.id) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } struct BookDetail: View { @Binding var id: Book.ID? @ObservedObject var store: ReadingListStore var body: some View { Text("Book Details") } } struct Book: Identifiable { var id: UUID } class ReadingListStore: ObservableObject { }
-
6:16 - Opening a window with a new document
import SwiftUI import UniformTypeIdentifiers @main struct TextFileApp: App { var body: some Scene { DocumentGroup(viewing: TextFile.self) { file in TextEditor(text: file.$document.text) } } } struct NewDocumentButton: View { @Environment(\.newDocument) private var newDocument var body: some View { Button("Open New Document") { newDocument(TextFile()) } } } struct TextFile: FileDocument { var text: String static var readableContentTypes: [UTType] { [UTType.plainText] } init() { text = "" } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents, let string = String(data: data, encoding: .utf8) else { throw CocoaError(.fileReadCorruptFile) } text = string } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let data = text.data(using: .utf8)! return FileWrapper(regularFileWithContents: data) } }
-
6:41 - Opening a window with an existing document
import SwiftUI import UniformTypeIdentifiers @main struct TextFileApp: App { var body: some Scene { DocumentGroup(viewing: TextFile.self) { file in TextEditor(text: file.$document.text) } } } struct OpenDocumentButton: View { var documentURL: URL @Environment(\.openDocument) private var openDocument var body: some View { Button("Open Document") { Task { do { try await openDocument(at: documentURL) } catch { // Handle error } } } } } struct TextFile: FileDocument { var text: String static var readableContentTypes: [UTType] { [UTType.plainText] } init() { text = "" } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents, let string = String(data: data, encoding: .utf8) else { throw CocoaError(.fileReadCorruptFile) } text = string } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let data = text.data(using: .utf8)! return FileWrapper(regularFileWithContents: data) } }
-
7:03 - Book details context menu button
struct OpenWindowButton: View { var book: Book @Environment(\.openWindow) private var openWindow var body: some View { Button("Open In New Window") { openWindow(value: book.id) } } } struct Book: Identifiable { var id: UUID }
-
7:08 - Book details context menu button
struct OpenWindowButton: View { var book: Book @Environment(\.openWindow) private var openWindow var body: some View { Button("Open In New Window") { openWindow(value: book.id) } } } struct Book: Identifiable { var id: UUID }
-
9:06 - Book Club app with book details Scene
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } WindowGroup("Book Details", for: Book.ID.self) { $bookId in BookDetail(id: $bookId, store: store) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } struct BookDetail: View { @Binding var id: Book.ID? @ObservedObject var store: ReadingListStore var body: some View { Text("Book Details") } } struct Book: Identifiable { var id: UUID } class ReadingListStore: ObservableObject { }
-
10:32 - Book Club app with book details Scene
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } WindowGroup("Book Details", for: Book.ID.self) { $bookId in BookDetail(id: $bookId, store: store) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } struct BookDetail: View { @Binding var id: Book.ID? @ObservedObject var store: ReadingListStore var body: some View { Text("Book Details") } } struct Book: Identifiable { var id: UUID } class ReadingListStore: ObservableObject { }
-
11:16 - Removing default commands for the book details scene
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } Window("Activity", id: "activity") { ReadingActivity(store: store) } WindowGroup("Book Details", for: Book.ID.self) { $bookId in BookDetail(id: $bookId, store: store) } .commandsRemoved() } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } struct BookDetail: View { @Binding var id: Book.ID? @ObservedObject var store: ReadingListStore var body: some View { Text("Book Details") } } struct Book: Identifiable { var id: UUID } class ReadingListStore: ObservableObject { }
-
11:46 - Extracting reading activity into custom scene
import SwiftUI @main struct BookClub: App { @StateObject private var store = ReadingListStore() var body: some Scene { WindowGroup { ReadingListViewer(store: store) } ReadingActivityScene(store: store) WindowGroup("Book Details", for: Book.ID.self) { $bookId in BookDetail(id: $bookId, store: store) } .commandsRemoved() } } struct ReadingActivityScene: Scene { @ObservedObject var store: ReadingListStore var body: some Scene { Window("Activity", id: "activity") { ReadingActivity(store: store) } } } struct ReadingListViewer: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading List") } } struct ReadingActivity: View { @ObservedObject var store: ReadingListStore var body: some View { Text("Reading Activity") } } struct BookDetail: View { @Binding var id: Book.ID? @ObservedObject var store: ReadingListStore var body: some View { Text("Book Details") } } struct Book: Identifiable { var id: UUID } class ReadingListStore: ObservableObject { }
-
12:04 - Applying the defaultPosition modifier
struct ReadingActivityScene: Scene { @ObservedObject var store: ReadingListStore var body: some Scene { Window("Activity", id: "activity") { ReadingActivity(store: store) } .defaultPosition(.topTrailing) } } class ReadingListStore: ObservableObject { }
-
12:32 - Applying the defaultSize modifier
struct ReadingActivityScene: Scene { @ObservedObject var store: ReadingListStore var body: some Scene { Window("Activity", id: "activity") { ReadingActivity(store: store) } #if os(macOS) .defaultPosition(.topTrailing) .defaultSize(width: 400, height: 800) #endif } } class ReadingListStore: ObservableObject { }
-
12:50 - Applying the keyboardShortcut modifier
struct ReadingActivityScene: Scene { @ObservedObject var store: ReadingListStore var body: some Scene { Window("Activity", id: "activity") { ReadingActivity(store: store) } #if os(macOS) .defaultPosition(.topTrailing) .defaultSize(width: 400, height: 800) #endif #if os(macOS) || os(iOS) .keyboardShortcut("0", modifiers: [.option, .command]) #endif } } class ReadingListStore: ObservableObject { }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.