Listing and closing Windows in a MacOS SwiftUI App

1.7k views Asked by At

I have this little sample App which creates multiple Windows of my SwiftUI MacOS app.
Is it possible:

  • to have a list of all open windows in MainView?
  • to close a single window from MainView?
  • to send Message to a single window from MainView?
@main
struct MultiWindowApp: App {
  @State var gvm = GlobalViewModel()
  var body: some Scene {
    WindowGroup {
      MainView()
      .environmentObject(gvm)
    }
    WindowGroup("Secondary") {
      SecondaryView(bgColor: .blue)
      .environmentObject(gvm)
    }
    .handlesExternalEvents(matching: Set(arrayLiteral: "*"))
  }
}


struct MainView: View {
  @Environment(\.openURL) var openURL
  @EnvironmentObject var vm : GlobalViewModel

  var body: some View {
    VStack {
      Text("MainView")
      Button("Open Secondary") {
        if let url = URL(string: "OpenNewWindowApp://bla") {
          openURL(url)
        }
       //List of all open Windows
          // Button to close a single window
          // Button to set color of  a single window to red
      }
    }
    .padding()
  }
}

struct SecondaryView: View {
  var bgColor : Color

  @EnvironmentObject var vm : GlobalViewModel

  var body: some View {
    VStack{
      Spacer()
      Text("Viewer")
      Text("ViewModel: \(vm.name)")
      Button("Set VM"){
        vm.name = "Tom"
      }
      Spacer()
    }
    .background(bgColor)
    .frame(minWidth: 300, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center )
  }
}

class GlobalViewModel :ObservableObject {
  @Published var name = "Frank"
}

1

There are 1 answers

1
jnpdx On BEST ANSWER

It is possible that there's a more SwiftUI-centric way to do this. If there's not yet, I certainly hope Apple adds some better window management stuff for the Mac side of things -- right now, everything seems like a bit of a hack.

Here's what I came up with:


@main
struct MultiWindowApp: App {
  @State var gvm = GlobalViewModel()
  var body: some Scene {
    WindowGroup {
      MainView()
      .environmentObject(gvm)
    }
    WindowGroup("Secondary") {
      SecondaryView(bgColor: .blue)
      .environmentObject(gvm)
    }
    .handlesExternalEvents(matching: Set(arrayLiteral: "*"))
  }
}


struct MainView: View {
  @Environment(\.openURL) var openURL
  @EnvironmentObject var vm : GlobalViewModel

  var body: some View {
    VStack {
      Text("MainView")
        List {
            ForEach(Array(vm.windows), id: \.windowNumber) { window in
                HStack {
                    Text("Window: \(window.windowNumber)")
                    Button("Red") {
                        vm.setColor(.red, forWindowNumber: window.windowNumber)
                    }
                    Button("Close") {
                        window.close()
                    }
                }
                
            }
        }
      Button("Open Secondary") {
        if let url = URL(string: "OpenNewWindowApp://bla") {
          openURL(url)
        }
      }
    }
    .padding()
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }
}

struct SecondaryView: View {
  var bgColor : Color
  @EnvironmentObject var vm : GlobalViewModel

    @State private var windowNumber = -1
    
  var body: some View {
    VStack{
        HostingWindowFinder { window in
            if let window = window {
                vm.addWindow(window: window)
                self.windowNumber = window.windowNumber
            }
        }
      Spacer()
      Text("Viewer")
      Text("ViewModel: \(vm.name)")
      Button("Set VM"){
        vm.name = "Tom"
      }
      Spacer()
    }
    .background(vm.backgroundColors[windowNumber] ?? bgColor)
    .frame(minWidth: 300, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center )
  }
}

class GlobalViewModel : NSObject, ObservableObject {
    @Published var name = "Frank"
    @Published var windows = Set<NSWindow>()
    @Published var backgroundColors : [Int:Color] = [:]
    
    func addWindow(window: NSWindow) {
        window.delegate = self
        windows.insert(window)
    }
    
    func setColor(_ color: Color, forWindowNumber windowNumber: Int) {
        backgroundColors[windowNumber] = color
    }
}

extension GlobalViewModel : NSWindowDelegate {
    func windowWillClose(_ notification: Notification) {
        if let window = notification.object as? NSWindow {
            windows = windows.filter { $0.windowNumber != window.windowNumber }
        }
    }
}

struct HostingWindowFinder: NSViewRepresentable {
    var callback: (NSWindow?) -> ()

    func makeNSView(context: Self.Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }
    func updateNSView(_ nsView: NSView, context: Context) {}
}

I'm using a trick from https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/ to get a reference to the NSWindow. That gets stored in the view model in a set. Later, to access things like closing the windows, etc. I reference the windows by windowNumber.

When a window appears, it adds itself to the view model's window list. Then, when the view model gets a windowWillClose call as the delegate, it removes it from the list.

Setting the background color is done via the backgroundColors property on the view model. If there's not one set, it uses the passed-in background color property. There are tons of different ways you could choose to architect this bit.