Hide a window with an invisible Push Window while presenting an immersive space
Another option for keeping an existing window open, but hiding it while presenting an immersive space.
See also:
Hide a window when presenting an immersive space
Using Push Window to hide the main app window in an immersive space
After sharing the two examples listed above, Drew Olbrich suggested we could combine them.
I had not considered that, but it turns out it works really well.
Setup
Starting in our app file, we’ll create a window using the plain window style. This will remove the system provided glass background.
struct Garden027App: App {
@State private var appModel = AppModel()
var body: some Scene {
WindowGroup(id: "MainWindow") {
ContentView()
.environment(appModel)
}
.defaultSize(CGSize(width: 600, height: 600))
WindowGroup(id: "PushWindow") {
PushWindowContent()
.environment(appModel)
}
.defaultSize(CGSize(width: 300, height: 300))
.windowStyle(.plain)
ImmersiveSpace(id: "GardenScene") {
ImmersiveView()
.environment(appModel)
}
.immersionStyle(selection: .constant(.full), in: .full)
}
}We’ll create a view for the push window. The only job this view has is to be presented and update our app model with its state. We can use opacity to hide any view content, and persistentSystemOverlays to hide the window controls.
struct PushWindowContent: View {
@Environment(AppModel.self) private var appModel
@Environment(\.scenePhase) private var scenePhase
var body: some View {
// We should never see this view
Text("Nothing to see here")
.opacity(0)
.persistentSystemOverlays(.hidden)
// the only thing this view needs to do is update the app model with its open state
.onChange(of: scenePhase, initial: true) {
switch scenePhase {
case .inactive, .background:
appModel.pushWindowOpen = false
case .active:
appModel.pushWindowOpen = true
@unknown default:
appModel.pushWindowOpen = false
}
}
}
}When we present the immersive space, we can also push this window.
// ContentView.swift
@Environment(\.pushWindow) private var pushWindow
// later, in the view
Task {
if(appModel.gardenOpen) {
await dismissImmersiveSpace()
return
} else if (!appModel.gardenOpen) {
await openImmersiveSpace(id: "GardenScene")
// Call Push Window
pushWindow(id: "PushWindow")
}
}When we need to exit the immersive space, we simply dismiss the pushed window. This will restore the main window.
Task {
await dismissImmersiveSpace()
if(appModel.pushWindowOpen) {
dismissWindow(id: "PushWindow")
}
}I love this approach! We don’t have to make major changes to our main window like in the first example. It also has the advantage of leaving our main window where we left it. We can’t move the pushed window without the drag bar, and we can’t show the drag bar without tapping on view content–which is hidden by opacity.
We could expand this further to support multiple windows. We would need to push and dismiss a unique window for each regular window in our app. It may be tricky, but it could be done. Let me know if you want to see that.
See this idea in action:
Video demo from Apple Vision Pro. A main window shows a button to enter the space. The window disappears, the space opens. The user taps 5 spheres, trigging the space to close. The main window reappears.
You can find the rest of the code in Garden 027 in this repo.
Support our work so we can continue to bring you new examples and articles.
Download the Xcode project with this and many more examples from Step Into Vision.
Some examples are provided as standalone Xcode projects. You can find those here.

One downside is the animation that shows the original window takes about a second longer than it takes for visionOS to restore windows from other apps.
I have an idea to add an alert window based on this, to let the user know that they will exit in 5 seconds, and this window will always face the user’s head. However, I failed to make this window face the user, and when I remove the sphere again within these 5 seconds, the alert window will appear again.