8

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:

Ondrej Kvasnovsky
  • 4,592
  • 3
  • 30
  • 40
  • Does this answer your question https://stackoverflow.com/a/67363964/12299030? – Asperi Jul 04 '21 at 07:34
  • I've found `WindowGroup`'s way of dealing with menus (meaning in the main menu bar), kind of limiting compared to AppKit. I have Swift package called [MacMenuBar](https://github.com/chipjarred/MacMenuBar) that provides what I think is a better model for menu handling in a SwiftUI way, but it's still set up to use an `AppDelegate` (I just haven't gotten around to updating it to work with `WindowGroup`). Even if it's not useful as-is, it may give you some ideas. – Chip Jarred Jul 04 '21 at 10:36
  • @Asperi that unfortunately does not answer the question, the question is: "How to update the model from the MenuCommands in case there are multiple windows (with its models) opened?" – Ondrej Kvasnovsky Jul 04 '21 at 19:40
  • @ChipJarred Thanks. Does that mean that there is no official/supported way (at this moment) to implement multi-window app that works with menu? – Ondrej Kvasnovsky Jul 05 '21 at 05:16
  • @OndrejKvasnovsky, No I don't mean to imply that there isn't a way. I commented rather than posting an answer because I wasn't actually answering your question, but rather just providing some information you might find helpful. The SwiftUI apps I've written that open new windows don't use `WindowGroup`, and I'd have to experiment with it to figure it out. I figured someone who has done it would be better to actually answer your question. – Chip Jarred Jul 05 '21 at 05:26
  • https://stackoverflow.com/questions/65935505/switfui-access-the-specific-scenes-viewmodel-on-macos/65959172#65959172 – lorem ipsum Jul 05 '21 at 17:47

2 Answers2

7

I came across this question when I was solving a similar problem. I believe the SwiftUI way is to use FocusedValue:

// create an active viewmodel key
struct ActiveViewModelKey: FocusedValueKey {
  typealias Value = ViewModel
}

extension FocusedValues {
  var activeViewModel: ViewModel? {
    get { self[ActiveViewModelKey.self] }
    set { self[ActiveViewModelKey.self] = newValue }
  }
}

struct ContentView: View {
  @StateObject var viewModel: ViewModel  = ViewModel()
  
  var body: some View {
    TextField("", text: $viewModel.inputText)
      ...
      .focusedSceneValue(\.activeViewModel, viewModel) // inject the focused value
  }
}

struct MenuCommands: Commands {
  @FocusedValue(\.activeViewModel) var activeViewModel // inject the active viewmodel
  
  var body: some Commands {
    CommandGroup(after: CommandGroupPlacement.newItem, addition: {
      Divider()
      Button(action: {
        ...
        activeViewModel?.inputText = string
      }, label: {
        Text("Open File")
      })
      .keyboardShortcut("O", modifiers: [.command])
    })
  }
}
Peter Kreinz
  • 7,979
  • 1
  • 64
  • 49
Yuxiao Zeng
  • 121
  • 2
  • 5
4

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

Ondrej Kvasnovsky
  • 4,592
  • 3
  • 30
  • 40
  • Added more details and video into this post: https://ondrej-kvasnovsky.medium.com/multi-window-swiftui-macos-app-working-with-menu-commands-4aff7d6c3bd6 – Ondrej Kvasnovsky Jul 11 '21 at 18:44
  • 1
    Awesome answer, I had a hunch it's gonna be something like this where we have to manage everything ourselves, but was hoping for a better method. But yours looks elegant enough. – John Zhou Aug 19 '21 at 07:11