TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

Doc-App global state with focusedSceneObject solves MacOS dynamic Menu issues

Forums > Swift

I'm only a few months into writing my first SwiftUI MacOS app. One of my hardest issues was how to connect MacOS menu items in a document-based app. I needed to have the MenuBar items be configured FROM the currrently active Document, and direct their action requests TO that same document. I found numerous sources that describe using focusedValue, but I found those to be limiting, syntacticly ugly and obscure. I finally found a mechanism that is simple, easy to explain and (most of all) very functional. I just want to share in case it helps someone else.

The example here is a direct edit of the Document-Based App template from Xcode. The sample (full listing at the end) allows you to open multiple text documents and provides a button in each window that opens a sheet on the window. If you open several documents at once, each of them gets its own button and they each can show the sheet or not. Simple so far. The real deal is that from the App level I can create a menu bar button that triggers showing the Sheet only on the currently selected document.
This is accomplished by having each document create its own Document State Object

    class DocState : ObservableObject {
          @Published var showSheet = false
    }

This contains a @Published property showSheet. All code that would normally reference a simple @State property now must refer to docState.showSheet. This may seem like making things more complicated, but it has its advantages.

In the ContentView we create the docState with

    @StateObject var docState = DocState()

and put it into the environment using

    .environmentObject( docState )

Now any subview can import the global state values with

    @EnvironmentObject var docState: DocState

The subview can use "docState.showSheet.toggle()" to show the sheet as well. But the environment is only accessible from sub-views and can't be accessed at the application level, that is, by the parent of ContentView. We can get around this by the final part of this method . We export a focused-scene-dependent reference to the docState. Here's how...

In ContentView, we add the modifier

      .focusedSceneObject( docState )

This looks just like exporting to the environment. Apple's documentation on this is sketchy, but I figure it works like this... The top level application maintains a collection of references to objects, keyed by the scene (i.e. document ContentView) they came from. When someone asks for the "current" object, they are handed the object that came from the currently focused Scene. You can see this in the App document. That includes this line:

      @FocusedObject var docState : DocState?

That tells the top-level app to create an optional object "docState" of the given class. It's optional because there won't be such an object if no document window is active - docState will be nil. All references to this structure must unwrap the value before usage. Now that the App level has access the the currrent document's state, we can use it, for example, to create document specific menus. In this sample, I added a DocMenu item to the menuBar, with a Button that activates the sheet. Furthermore, I disable that button if there is no active document window.

     .commands {
          CommandMenu( "DocMenu" ) {
              Button( "Show Sheet") {
                  docState!.showSheet.toggle()
              }
              .disabled( docState == nil )
          }
      }

That's it! We have connected the App level code to the currently active document, using shared state variables. Activating the menu button Show Sheet will open a sheet ONLY on the currently selected document. And if you close all the documents, the Show Sheet button will be grayed-out.

What next? The sky is the limit!

  • You can add multiple @Published properties to the DocState structure. They become instantly shared.
  • ContentView and sub-views can all easily access the properties, use them to control views, trigger actions with .onChange(), etc.
  • You can put method functions in DocState and they can be called from the ContentView or the App-level menus - but they only have access to the values inside the structure.
  • You can put a state property in DocState that can be used by an App-menu to trigger actions in ContentView. For example, you could make a property, toggle it in an App-Menu, monitor it in ContentView with onChange(), and execute anything you want with the document or view structure.
  • You can set a state property in ContentView and have it be reflected in the menu settings at the App level. For example, a menu button name could be changed to match some value set in a document view. Switch documents and the menu button automatically adapts. For example, I've implement a multiple-item top-level menu whose content and length is dependent on a variable length list of items from my document. The menu drawing is done in the App code. But the data comes from the ContentView/document.
  • Menu items can be disabled, or made visible/invisible by state properties published by the ContentView.

All of this happens pretty much automatically, once you have exported the document state to the app. It is making my app a lot better. Regards to HWS for posting so much useful information. Hopefully this will give someone else the ability to make a cleaner MacOS app. Tom Coates :::/

Here are the complete versions of the sample code. I would highlight the changes from Xcode's template, but I'm still figuring out the editing tools here.

class DocState : ObservableObject {
    @Published var showSheet = false
}
​
struct ContentView: View {
    @Binding var document: DemoDocument
    
    @StateObject var docState = DocState()
    
    var body: some View {
        VStack {
            Button( "Show Sheet") {docState.showSheet.toggle() }
            TextEditor(text: $document.text)
        }
        .sheet(isPresented:$docState.showSheet) {
            Text( "TheSheet")
                .font(.title)
                .padding()
        }
        .focusedSceneObject( docState )
        .environmentObject( docState )
    }
}

struct DemoApp: App {
    @FocusedObject var docState : DocState?
​
    var body: some Scene {
        DocumentGroup(newDocument: DemoDocument()) { file in
            ContentView(document: file.$document)
        }
        .commands {
            CommandMenu( "DocMenu" ) {
                Button( "Show Sheet") {
                    docState!.showSheet.toggle()
                }
                .disabled( docState == nil )
            }
        }
    }
}
​

1      

Hacking with Swift is sponsored by Blaze.

SPONSORED Still waiting on your CI build? Speed it up ~3x with Blaze - change one line, pay less, keep your existing GitHub workflows. First 25 HWS readers to use code HACKING at checkout get 50% off the first year. Try it now for free!

Reserve your spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.