Niek
Niek

Reputation: 1609

Conditionally rendering a MenuBarExtra

I'm using SwiftUI to write a macOS app and my app includes a menu bar item. I want to display one of two 'types' of menu bar items (one with a ticking timer, one that's a static image) depending on some global setting.

However, I'm running into an issue where Swift expects the underlying Scene types to be identical, which they aren't, because I'm using two different initializers:

@main
struct MyApp: App {
    var body: some Scene {
        self.menuBarExtra()
        ContentView()
    }

    // ERROR: Function declares an opaque return type 'some Scene', but the return statements in its body do not have matching underlying types
    private func menuBarExtra() -> some Scene {
        if $showTimerInMenuBar {
            return MenuBarExtra(content: {AppMenu()}, label: {Text(timeRemainingFormatted)})
        }
        else {
            return MenuBarExtra("App", systemImage: "someImage") {
                AppMenu()
            }
        }
    }
}

Is there a way to work around this problem? I understand what opaque types are and what the compiler is telling me, but I'm not sure how to resolve it and achieve the functionality I want.

Upvotes: 0

Views: 299

Answers (2)

Niek
Niek

Reputation: 1609

I eventually got this to work by using a type-erased View (using AnyView) as the Label for the MenuBarExtra:

private func menuBarExtra() -> some Scene {
    @AppStorage("showMenuBarIcon") var showMenuBarIcon = true
    @AppStorage("showTimeLeftInMenuBar") var showTimeLeftInMenuBar = false
    var label: AnyView

    if showTimeLeftInMenuBar {
        label = AnyView(Text(timerViewModel.timeRemainingFormatted))
    }
    else {
        if timerViewModel.timerIsRunning {
            label = AnyView(Image(systemName: "stop.circle"))
        }

        else {
            let systemImage = timerViewModel.timerIsFull ? "stop.circle" : "pause.circle"
            label = AnyView(Image(systemName: systemImage))
        }
    }

    return MenuBarExtra(isInserted: $showMenuBarIcon, content: {AppMenu()}, label: {label})
}

Upvotes: 0

iSpain17
iSpain17

Reputation: 3053

The problem is that when you say your function's return value is some Scene, Swift expects a single type, as you mention. Function/Result builders do solve this issue usually, like ViewBuilder, SceneBuilder (what you could need here) and others by being able to take several items conforming to the return protocol (Scene here) and converting them into a single item.

However, your desired scenario is sadly not possible currently. If you look at App's var body you can see that is a @SceneBuilder. SceneBuilders can take multiple Scenes as closure inputs and they'll be converted to another Scene. However, @SceneBuilder (unlike @ViewBuilder) cannot handle if-else clauses as explained on the buildOptional method for SceneBuilder.

Conditional statements in a SceneBuilder can contain an if statement but not an else statement, and the condition can only perform a compiler check for availability [...].

So instead, things you could try:

  1. Putting your conditional content in a ViewBuilder in a single, always present MenuBarExtra where you can use if-else clauses. I don't see why that wouldn't be possible given your example, both branches of your if-else create a MenuBarExtra, simply with different content.

  2. MenuBarExtra also seems to have initializers with an isInserted argument, which might be what you need:

     @SceneBuilder
     private func menuBarExtra() -> some Scene {
         MenuBarExtra(isInserted: $showTimerInMenuBar, content: { AppMenu() }, label: { Text(timeRemainingFormatted) })
    
         MenuBarExtra("App", systemImage: "someImage", isInserted: [an inverted Binding to showTimerInMenuBar]) {
             AppMenu()
         }
     }
    

    Consult this thread on how to create an inverted Binding.

Upvotes: 1

Related Questions