0

Situation: I need the menu bar to recognise the active tab in a TabView, even when multiple windows are open. I have found this solution (https://stackoverflow.com/a/68334001/2682035), which seems to work in principal, but not in practice.

Problem: Menu bar buttons do not disable immediately when their corresponding tab is changed. However, they will disable correctly if another State variable is modified.

Minimal example which now works: I made this without the code for managing multiple windows because the problem seemed to occur without that. Thanks to the suggestion from lorem ipsum, this will now function as expected.

import SwiftUI

enum TabType: String, Codable{
    case tab1 = "first tab"
    case tab2 = "second tab"
}

public class ViewModel: ObservableObject {
    @Published var activeTab: TabType = .tab1
}

struct MenuCommands: Commands {
    @ObservedObject var viewModel: ViewModel // CHANGED
    @Binding var someInformation: String
    
    var body: some Commands{
        CommandMenu("My menu"){
            Text(someInformation)
            Button("Go to tab 1"){
                viewModel.activeTab = .tab1
            }
            .disabled(viewModel.activeTab == .tab1) // this now works as expected
            Button("Go to tab 2"){
                viewModel.activeTab = .tab2
            }
            .disabled(viewModel.activeTab == .tab2) // this does too
            Button("Print active tab"){
                print(viewModel.activeTab) // this does too
            }
        }
    }
}

struct Tab: View{
    var tabText: String
    @Binding var someInformation: String
    
    var body: some View{
        VStack{
            Text("Inside tab " + tabText)
            TextField("Info", text: $someInformation)
        }
    }
}


struct ContentView: View {
    
    @EnvironmentObject var viewModel: ViewModel
    @Binding var someInformation: String
    
    var body: some View {
        TabView(selection: $viewModel.activeTab){
            Tab(tabText: "1", someInformation: $someInformation)
                .tabItem{
                    Label("Tab 1", systemImage: "circle")
                }
                .tag(TabType.tab1)
            Tab(tabText: "2", someInformation: $someInformation)
                .tabItem{
                    Label("Tab 2", systemImage: "circle")
                }
                .tag(TabType.tab2)
        }
    }
}

@main
struct DisableMenuButtonsMultiWindowApp: App {
    
    @StateObject var viewModel = ViewModel() // CHANGED
    @State var someInformation: String = ""
    
    var body: some Scene {
        WindowGroup {
            ContentView(someInformation: $someInformation)
                .environmentObject(viewModel)
        }
        .commands{MenuCommands(viewModel: viewModel, someInformation: $someInformation)}
    }
}

Slightly less minimal example that doesn't work:

Unfortunately that didn't work in my app, so here is a new minimal example that works closer to the actual app and will observe multiple windows, except it has the same issue as before.

import SwiftUI

enum TabType: String, Codable{
    case tab1 = "first tab"
    case tab2 = "second tab"
}

public class ViewModel: ObservableObject {
    @Published var activeTab: TabType = .tab1
}

struct MenuCommands: Commands {
    @ObservedObject var globalViewModel: GlobalViewModel
    @Binding var someInformation: String
    
    var body: some Commands{
        CommandMenu("My menu"){
            Text(someInformation)
            Button("Go to tab 1"){
                globalViewModel.activeViewModel?.activeTab = .tab1
            }
            .disabled(globalViewModel.activeViewModel?.activeTab == .tab1) // this will not disable when activeTab changes, but it will when someInformation changes
            Button("Go to tab 2"){
                globalViewModel.activeViewModel?.activeTab = .tab2
            }
            .disabled(globalViewModel.activeViewModel?.activeTab == .tab2) // this will not disable when activeTab changes, but it will when someInformation changes
            Button("Print active tab"){
                print(globalViewModel.activeViewModel?.activeTab ?? "") // this always returns correctly
            }
        }
    }
}

struct Tab: View{
    var tabText: String
    @Binding var someInformation: String
    
    var body: some View{
        VStack{
            Text("Inside tab " + tabText)
            TextField("Info", text: $someInformation)
        }
    }
}


struct ContentView: View {
    
    @EnvironmentObject var globalViewModel : GlobalViewModel
    @Binding var someInformation: String
    
    @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)
          }
        }
        
        TabView(selection: $viewModel.activeTab){
            Tab(tabText: "1", someInformation: $someInformation)
                .tabItem{
                    Label("Tab 1", systemImage: "circle")
                }
                .tag(TabType.tab1)
            Tab(tabText: "2", someInformation: $someInformation)
                .tabItem{
                    Label("Tab 2", systemImage: "circle")
                }
                .tag(TabType.tab2)
        }
    }
}

@main
struct DisableMenuButtonsMultiWindowApp: App {
    
    @StateObject var globalViewModel = GlobalViewModel()
    @State var someInformation: String = ""
    
    var body: some Scene {
        WindowGroup {
            ContentView(someInformation: $someInformation)
                .environmentObject(globalViewModel)
        }
        .commands{MenuCommands(globalViewModel: globalViewModel, someInformation: $someInformation)}
    }
}

// everything below is from other solution for observing multiple windows

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?
  
  override init() {
    super.init()
    // deallocate a window when it is closed
    // thanks for this Maciej Kupczak 
    NotificationCenter.default.addObserver(
      self,
      selector: #selector(onWillCloseWindowNotification(_:)),
      name: NSWindow.willCloseNotification,
      object: nil
    )
  }
  
  @objc func onWillCloseWindowNotification(_ notification: NSNotification) {
    guard let window = notification.object as? NSWindow else {
      return
    }
    var viewModels = self.viewModels
    viewModels.removeValue(forKey: window.windowNumber)
    self.viewModels = viewModels
  }
  
  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
  }
}

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)
      // windows = windows.filter { $0.windowNumber != window.windowNumber }
    }
  }
  func windowDidBecomeKey(_ notification: Notification) {
    if let window = notification.object as? NSWindow {
      print("Activating Window", window.windowNumber)
      activeWindow = window
      activeViewModel = viewModels[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) {}
}

What I've tried: Changing viewModel to Binding, StateObject and now changing to StateObject and Observed Object. It also doesn't matter if it's sent to the ContentView as a Binding or EnvironmentObject, it still happens.

1 Answers1

0

In DisableMenuButtonsMultiWindowApp change

@State var viewModel = ViewModel()

To

@StateObject var viewModel: ViewModel = ViewModel()

And in the MenuCommands use

@ObservedObject var viewModel: ViewModel

instead

https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

@State is used to initialize value types such as a struct and @StateObject is for reference type ObservableObjects such as your ViewModel.

Something else to note is that each @State does not know about changes to another @State an @Binding is a two-way connection.

lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • Thanks so much! I'm always getting the difference between those mixed up. That worked in the minimal example. Unfortunately, when I went to go try it in my actual app, it didn't work. Therefore I've supplied a new (slightly less) minimal example. – jaimepapier Aug 22 '22 at 14:48
  • @jaimepapier then that would be a different question but the issue is that you are Chaining ObervableObservable objects. The dictionary of view models does not work as you expect. If I answered your original question please accept the answer with the green check mark. – lorem ipsum Aug 22 '22 at 15:08
  • It's alright, I got it working. I just changed ` .commands{MenuCommands(globalViewModel: globalViewModel, someInformation: $someInformation)} ` to `.commands{MenuCommands(activeViewModel: globalViewModel.activeViewModel ?? ViewModel(), someInformation: $someInformation)}` and then used activeViewModle instead of the global version in the command line. I tried that before but it didn't work because I hadn't set up the ObservableObjects correctly (as you said). Thanks again! – jaimepapier Aug 22 '22 at 16:44