How to build Synced Window Sets in visionOS

Learn how to use two simple SwiftUI features to build a set of Windows that can be moved as a group.

Overview

This example builds on two simple SwiftUI / visionOS concepts. It’s important that you understand these concepts first. These are defaultWindowPlacement and onGeometryChange3D

Let’s start with a main window and two secondary windows. Notice that the secondary windows use defaultWindowPlacement to open to the left and right of the main window.

struct Garden035App: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultSize(width: 500, height: 500)

        WindowGroup(id: "YellowFlower") {
            Text("🌸")
                .font(.system(size: 128))
        }
        .defaultSize(CGSize(width: 300, height: 200))
        .defaultWindowPlacement { _, context in
            if let mainWindow = context.windows.first {
                return WindowPlacement(.leading(mainWindow))
            }
            return WindowPlacement(.none)
        }

        WindowGroup(id: "PinkFlower") {
            Text("🌼")
                .font(.system(size: 128))
        }
        .defaultSize(CGSize(width: 300, height: 200))
        .defaultWindowPlacement { _, context in
            if let mainWindow = context.windows.first {
                return WindowPlacement(.trailing(mainWindow))
            }
            return WindowPlacement(.none)
        }
    }
}

In our ContentView, we can use onGeometryChange3D with Point3D. This will allow us to read the position of the main window relative to world space. This value changes when the user re-centers their view or when they move the window. With a bit of state tracking, we can use these changes to determine when a window move started or ended. We’ll use a timer and some debouncing logic to create an end/stop condition.

    .onGeometryChange3D(for: Point3D.self) { proxy in try! proxy
            .coordinateSpace3D()
            .convert(value: Point3D.zero, to: .worldReference)
    } action: { old, new in
        worldPosiiton = new
        
        // Mark as moving when a new change arrives
        if !isMoving {
            isMoving = true
            print("🟢 Window movement began at \(worldPosiiton)")
        }
        
        // Reset debounce timer; when it fires, consider movement ended
        movementEndTimer?.invalidate()
        movementEndTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { _ in
            isMoving = false
            // This is your "stopped moving" event
            print("🛑 Window movement stopped at \(worldPosiiton)")
        }
    }

Now we can determine if our secondary windows should open or close. When we start moving the main window, we close the secondary windows. When we stop moving the main window, we reopen them. This concept works well when moving the main window, as the other windows are positioned around that. I’m not sure how this would work when extended to moving the secondary windows.

Video Demo

A video demo shows the secondary windows close and reopen based on the movement of the main window.

Sample code for this post is available in Garden035 in Step Into Examples on GitHub

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.

Questions or feedback?