Search code examples
swiftswiftuimenubarextra

How to change state outside of a view?


I have a menubar-only (Mac) SwiftUI app. The core of it is this:

// MyMainApp.swift
var body: some Scene {
    MenuBarExtra("My App", image: "LoggedOutIcon") {
        MenuBarView()
    }
}

I'd like to dynamically update the image to "LoggedInIcon" whenever the user is logged in, and "LoggedOutIcon" whenever the user is logged out. That functionality is set in a (non-view) controller.

I figured the way to do this was to pass in my main controller to my app like this:

// MyMainApp.swift
@StateObject var myMainController = MyMainController()

Within that, set a published variable like this:

// MyMainController.swift
@Published var loggedIn: Bool = false

And then update the MenuBarExtra call to

// MyMainApp.swift
MenuBarExtra("My App", image: myMainController.loggedIn ? "LoggedInIcon" : "LoggedOutIcon")

The good news is: it works. The bad news is, that @StateObject var myMainController line raises the following purple notification of doom:

Accessing StateObject's object without being installed on a View. This will create a new instance each time.

...That seems like something I should avoid.

In short: what is the best practice for updating a MenuBarExtra icon in a SwiftUI app outside the scope of a view?


Solution

  • If the loggedIn property is toggled by a view further down in your hierarchy-- e.g. in MenuBarView:

    struct MenuBarView: View {
        @Binding var loggedIn: Bool
        var body: some View {
            Button(action: { loggedIn.toggle() }) {
                Text(loggedIn ? "Log Out" : "Log In")
            }
        }
    }
    

    Then you can use the @AppStorage property wrapper and in MyMainApp.Swift:

    @AppStorage("loggedIn") private var loggedIn = false
    var body: some Scene {
            MenuBarExtra("My App", image: loggedIn ? "LoggedInIcon" : "LoggedOutIcon") {
                MenuBarView(loggedIn: $loggedIn)
            }
        }
    

    If you want it to be toggled in the MyMainController, you'd still use @AppStorage but instead of having a @Binding to it in a View, you'd do:

    // MyMainController.swift
    var loggedIn: Bool {
            didSet {
                UserDefaults.standard.set(loggedIn, forKey: "loggedIn")
            }
        }
    

    If you're interested in why swift is showing you this error, there is a good discussion about it here