Last active
May 10, 2025 18:12
-
-
Save Matt54/d8b0e52cb1333bc41e8715f2a1836bad to your computer and use it in GitHub Desktop.
visionOS combine and split window management idea
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
@main | |
struct CombineAndSplitViewExampleApp: App { | |
@State var viewStateManager = ViewStateManager() | |
var body: some Scene { | |
// Main Window allows us to launch first CombineAndSplitView Window with appropriate ViewWindowID value | |
// (if we just had CombineAndSplitView open initially, it would be associated with nil) | |
// There's probably a way to handle the nil case too, but this already covers my needs | |
WindowGroup(id: mainWindowID) { | |
MainView() | |
} | |
.windowResizability(.contentSize) | |
WindowGroup(for: ViewWindowID.self) { id in | |
CombineAndSplitView(windowID: id.wrappedValue?.windowID ?? firstWindowID) | |
.environment(viewStateManager) | |
} | |
.defaultSize(CGSize(width: 350, height: 350)) // sets how far away the window gets placed from the default window placement | |
.windowResizability(.contentSize) | |
.defaultWindowPlacement { content, context in | |
if let mainWindow = context.windows.first { | |
if viewStateManager.isMainWindowOpen { | |
// Appear centered with main view | |
// I know it's depreciated but PushWindowAction has back navigation | |
return WindowPlacement(.replacing(mainWindow)) | |
} else if viewStateManager.isFirstWindowOpen{ | |
return WindowPlacement(.trailing(mainWindow)) | |
} else { | |
return WindowPlacement(.leading(mainWindow)) | |
} | |
} | |
return WindowPlacement(.none) | |
} | |
} | |
} | |
struct MainView: View { | |
@Environment(\.openWindow) var openWindow | |
var body: some View { | |
Button( | |
action: { | |
// Open instead of push because push would reshow main on first window dismissal | |
openWindow(value: ViewWindowID(windowID: firstWindowID)) | |
}, | |
label: { | |
Text("Open Split View") | |
.font(.largeTitle) | |
.padding() | |
} | |
) | |
.padding() | |
} | |
} | |
enum ViewConfiguration: Codable { | |
case leftOnly | |
case rightOnly | |
case combined | |
case split | |
} | |
struct CombineAndSplitView: View { | |
@Environment(ViewStateManager.self) var manager | |
@Environment(\.openWindow) var openWindow | |
@Environment(\.dismissWindow) private var dismissWindow | |
let windowID: String | |
// used for immediate feedback on pressing ornament | |
@State private var isTransitioning: Bool = false | |
var body: some View { | |
HStack { | |
if isShowingLeft { | |
ExampleView1().frame(width: viewWidth) | |
} | |
if isShowingRight { | |
ExampleView2().frame(width: viewWidth) | |
} | |
} | |
.frame(minWidth: combinedViewWidth, maxWidth: combinedViewWidth, | |
minHeight: viewWidth, maxHeight: viewWidth) | |
.animation(.default, value: manager.configuration) | |
.ornament(attachmentAnchor: .scene(.topTrailing), contentAlignment: .center) { | |
if isShowingLeft && !isTransitioning { | |
Button(action: { | |
if manager.configuration == .split { | |
print("Got dismissed with \(otherWindowID)") | |
dismissWindow(value: ViewWindowID(windowID: otherWindowID)) | |
} | |
switch manager.configuration { | |
case .combined: | |
manager.configuration = .leftOnly | |
case .leftOnly, .rightOnly, .split: | |
if manager.configuration == .split { | |
transitionFromSplitToCombined() | |
} else { | |
manager.configuration = .combined | |
} | |
} | |
}, label: { | |
Image(systemName: "rectangle.portrait.and.arrow.right.fill") | |
.rotationEffect(Angle(degrees: manager.configuration == .combined ? 180 : 0)) | |
}) | |
.labelStyle(.iconOnly) | |
} | |
} | |
.ornament(attachmentAnchor: .scene(.top), contentAlignment: .bottom) { | |
if manager.configuration == .combined { | |
Button(action: { | |
openWindow(value: ViewWindowID(windowID: otherWindowID)) | |
manager.configuration = .split | |
}, label: { | |
Image(systemName: "rectangle.split.2x1.fill") | |
}) | |
.labelStyle(.iconOnly) | |
} | |
} | |
.ornament(attachmentAnchor: .scene(.topLeading), contentAlignment: .center) { | |
if isShowingRight && !isTransitioning { | |
Button(action: { | |
if manager.configuration == .split { | |
print("Got dismissed with \(otherWindowID)") | |
dismissWindow(value: ViewWindowID(windowID: otherWindowID)) | |
} | |
switch manager.configuration { | |
case .combined: | |
manager.configuration = .rightOnly | |
case .leftOnly, .rightOnly, .split: | |
if manager.configuration == .split { | |
transitionFromSplitToCombined() | |
} else { | |
manager.configuration = .combined | |
} | |
} | |
}, label: { | |
Image(systemName: "rectangle.portrait.and.arrow.right.fill") | |
.rotationEffect(Angle(degrees: manager.configuration != .combined ? 180 : 0)) | |
}) | |
.labelStyle(.iconOnly) | |
} | |
} | |
.onAppear { | |
// Dismissing main to avoid "Open Split View" button being tapped again | |
if manager.isMainWindowOpen { | |
dismissWindow(id: mainWindowID) | |
manager.isMainWindowOpen = false | |
} | |
if windowID == firstWindowID { | |
manager.isFirstWindowOpen = true | |
} else { | |
manager.isSecondWindowOpen = true | |
} | |
} | |
.onDisappear { | |
if windowID == firstWindowID { | |
manager.isFirstWindowOpen = false | |
} else { | |
manager.isSecondWindowOpen = false | |
} | |
} | |
} | |
func transitionFromSplitToCombined() { | |
isTransitioning = true | |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { | |
isTransitioning = false | |
manager.configuration = .combined | |
}) | |
} | |
struct ExampleView1: View { | |
var body: some View { | |
Text("1") | |
.foregroundStyle(.yellow) | |
.font(.extraLargeTitle) | |
} | |
} | |
struct ExampleView2: View { | |
var body: some View { | |
Text("2") | |
.foregroundStyle(.green) | |
.font(.extraLargeTitle) | |
} | |
} | |
var otherWindowID: String { | |
windowID == firstWindowID ? secondWindowID : firstWindowID | |
} | |
let viewWidth: CGFloat = 300 | |
var combinedViewWidth: CGFloat { | |
manager.configuration == .combined ? 600 : 300 | |
} | |
var isShowingLeft: Bool { | |
if manager.configuration == .leftOnly || manager.configuration == .combined { | |
return true | |
} | |
if manager.configuration == .split && windowID == firstWindowID { | |
return true | |
} | |
return false | |
} | |
var isShowingRight: Bool { | |
if manager.configuration == .rightOnly || manager.configuration == .combined { | |
return true | |
} | |
if manager.configuration == .split && windowID == secondWindowID { | |
return true | |
} | |
return false | |
} | |
} | |
@Observable | |
class ViewStateManager { | |
var configuration: ViewConfiguration = .leftOnly | |
var isMainWindowOpen: Bool = true | |
var isFirstWindowOpen: Bool = false | |
var isSecondWindowOpen: Bool = false | |
} | |
// Some unique type to catch in scene/window launching that passes an associated id | |
struct ViewWindowID: Hashable, Codable { | |
let windowID: String | |
} | |
// two id's to conveniently juggle between having 1 or 2 windows open | |
// gives us an id to dismiss the other window as needed | |
let mainWindowID: String = "MainWindow" | |
let firstWindowID: String = "1" | |
let secondWindowID: String = "2" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment