Search code examples
swiftmacosswiftui

How to implement multi window with menu commands in SwiftUI for macOS?


Situation

Implement a multi window application, where each window has its own state.

Example

Here is an example (on github) to showcase the question:

import SwiftUI

@main
struct multi_window_menuApp: App {

  var body: some Scene {
    WindowGroup {
      ContentView()
    }.commands {
      MenuCommands()
    }
  }
}

struct ContentView: View {
  @StateObject var viewModel: ViewModel  = ViewModel()
  
  var body: some View {
    TextField("", text: $viewModel.inputText)
      .disabled(true)
      .padding()
  }
}

public class ViewModel: ObservableObject {
  
  @Published var inputText: String = "" {
    didSet {
      print("content was updated...")
    }
  }
}

Question

How should we programmatically figure out what is the currently selected view so we can update the state when the menu command is about to finish and update the state in the view model?

import Foundation
import SwiftUI
import Combine

struct MenuCommands: Commands {
  
  var body: some Commands {
    CommandGroup(after: CommandGroupPlacement.newItem, addition: {
      Divider()
      Button(action: {
        let dialog = NSOpenPanel();
        
        dialog.title = "Choose a file";
        dialog.showsResizeIndicator = true;
        dialog.showsHiddenFiles = false;
        dialog.allowsMultipleSelection = false;
        dialog.canChooseDirectories = false;
        
        if (dialog.runModal() ==  NSApplication.ModalResponse.OK) {
          let result = dialog.url
          if (result != nil) {
            let path: String = result!.path
            do {
              let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
              print(string)
              // how to get access to the currently active view model to update the inputText variable?
              // viewModel.inputText = string
            }
            catch {
              print("Error \(error)")
            }
          }
        } else {
          return
        }
      }, label: {
        Text("Open File")
      })
      .keyboardShortcut("O", modifiers: .command)
    })
  }
}

Links that might be useful to figure this out:


Solution

  • Useful links:

    1. How to access NSWindow from @main App using only SwiftUI?
    2. How to access own window within SwiftUI view?
    3. https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/

    (this is what I was able to come up with, if anyone has a better idea/approach, please share)

    The idea is to create a shared "global" view model that keeps track of opened windows and view models. Each NSWindow has an attribute with a unique windowNumber. When a window becomes active (key), it looks up the view model by the windowNumber and sets it as the activeViewModel.

    import SwiftUI
    
    class GlobalViewModel : NSObject, ObservableObject {
      
      // all currently opened windows
      @Published var windows = Set<NSWindow>()
      
      // all view models that belong to currently opened windows
      @Published var viewModels : [Int:ViewModel] = [:]
      
      // currently active aka selected aka key window
      @Published var activeWindow: NSWindow?
      
      // currently active view model for the active window
      @Published var activeViewModel: ViewModel?
      
      func addWindow(window: NSWindow) {
        window.delegate = self
        windows.insert(window)
      }
      
      // associates a window number with a view model
      func addViewModel(_ viewModel: ViewModel, forWindowNumber windowNumber: Int) {
        viewModels[windowNumber] = viewModel
      }
    }
    

    Then, react on every change on window (when it is being closed and when it becomes an active aka key window):

    import SwiftUI
    
    extension GlobalViewModel : NSWindowDelegate {
      func windowWillClose(_ notification: Notification) {
        if let window = notification.object as? NSWindow {
          windows.remove(window)
          viewModels.removeValue(forKey: window.windowNumber)
          print("Open Windows", windows)
          print("Open Models", viewModels)
        }
      }
      func windowDidBecomeKey(_ notification: Notification) {
        if let window = notification.object as? NSWindow {
          print("Activating Window", window.windowNumber)
          activeWindow = window
          activeViewModel = viewModels[window.windowNumber]
        }
      }
    }
    

    Provide a way to lookup window that is associated to the current view:

    import SwiftUI
    
    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) {}
    }
    

    Here is the view that is updating the global view model with the current window and viewModel:

    import SwiftUI
    
    struct ContentView: View {
      @EnvironmentObject var globalViewModel : GlobalViewModel
      
      @StateObject var viewModel: ViewModel  = ViewModel()
      
      var body: some View {
        HostingWindowFinder { window in
          if let window = window {
            self.globalViewModel.addWindow(window: window)
            print("New Window", window.windowNumber)
            self.globalViewModel.addViewModel(self.viewModel, forWindowNumber: window.windowNumber)
          }
        }
        
        TextField("", text: $viewModel.inputText)
          .disabled(true)
          .padding()
      }
    }
    

    Then we need to create the global view model and send it to the views and commands:

    import SwiftUI
    
    @main
    struct multi_window_menuApp: App {
      
      @State var globalViewModel = GlobalViewModel()
      
      var body: some Scene {
        WindowGroup {
          ContentView()
            .environmentObject(self.globalViewModel)
        }
        .commands {
          MenuCommands(globalViewModel: self.globalViewModel)
        }
        
        Settings {
          VStack {
            Text("My Settingsview")
          }
        }
      }
    }
    

    Here is how the commands look like, so they can access the currently selected/active viewModel:

    import Foundation
    import SwiftUI
    import Combine
    
    struct MenuCommands: Commands {
      var globalViewModel: GlobalViewModel
      
      var body: some Commands {
        CommandGroup(after: CommandGroupPlacement.newItem, addition: {
          Divider()
          Button(action: {
            let dialog = NSOpenPanel();
            
            dialog.title = "Choose a file";
            dialog.showsResizeIndicator = true;
            dialog.showsHiddenFiles = false;
            dialog.allowsMultipleSelection = false;
            dialog.canChooseDirectories = false;
            
            if (dialog.runModal() ==  NSApplication.ModalResponse.OK) {
              let result = dialog.url
              if (result != nil) {
                let path: String = result!.path
                do {
                  let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
                  print("Active Window", self.globalViewModel.activeWindow?.windowNumber)
                  self.globalViewModel.activeViewModel?.inputText = string
                }
                catch {
                  print("Error \(error)")
                }
              }
            } else {
              return
            }
          }, label: {
            Text("Open File")
          })
          .keyboardShortcut("O", modifiers: [.command])
        })
      }
    }
    

    All is updated and runnable under this github project: https://github.com/ondrej-kvasnovsky/swiftui-multi-window-menu