Search code examples
swiftswiftuimenubar

SwiftUI View not updating when embedded into NSHostingView


I am trying to use a custom View in the menu bar. The View ist displayed, however, the text is not updated properly.

I have the following code:

@main
struct MenuBarTestApp: SwiftUI.App {
    
    @StateObject var model = MainModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class MainModel : ObservableObject
{
    private var item: NSStatusItem?
    private var timer: Timer?
    
    @Published var upper: Double = 0.0
    @Published var lower: Double = 0.0
    
    init()
    {
        // TODO This is a workaround. Calling this code without a small delay crashes the app immediately.
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            let hostingView = NSHostingView(rootView: TestView(upper: self.upper, lower: self.lower))
            let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
            item.length = 96.0
            item.button?.addSubview(hostingView)
            hostingView.constraintToSuperview()
            
            self.item = item
        }
        
        self.timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) {
            _ in
            
            self.upper = Double.random(in: 0.0...1.0)
            self.lower = Double.random(in: 0.0...1.0)
        }
    }
}

struct TestView : View
{
    var upper: Double
    var lower: Double
    
    var body: some View
    {
        VStack(spacing: 0.0) {
            HStack {
                Image(systemName: "circle.fill")
                    .resizable()
                    .frame(width: 6.0, height: 6.0)
                
                Text(self.upper.description)
                    .font(Font.system(size: 8.0))
            }
            
            HStack {
                Image(systemName: "circle.fill")
                    .resizable()
                    .frame(width: 6.0, height: 6.0)
                
                Text(self.lower.description)
                    .font(Font.system(size: 8.0))
            }
        }
    }
}

internal extension NSView
{
    func constraintToSuperview()
    {
        guard let superview = self.superview else { return }
        
        self.translatesAutoresizingMaskIntoConstraints = false
        self.topAnchor.constraint(equalTo: superview.topAnchor, constant: 0.0).isActive = true
        self.bottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: 0.0).isActive = true
        self.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 0.0).isActive = true
        self.trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: 0.0).isActive = true
    }
}

The Timer updates both variables properly. However, the texts never update in the menu bar. I thought that it may have something to do with the wrong property wrappers so I tried to use the new @Observable macro from macOS 14. But the menu bar View is never updated as well.

What am I missing here?


Solution

  • You are passing static values to your View when you do this:

    TestView(upper: self.upper, lower: self.lower)
    

    TestView never has any reason to update because it is created once with those values.

    To get your View to update, the easiest approach would be giving it a reference to the ObservableObject you've created:

    struct TestView : View
    {
        @ObservedObject var mainModel: MainModel
        
        var body: some View
        {
            VStack(spacing: 0.0) {
                HStack {
                    Image(systemName: "circle.fill")
                        .resizable()
                        .frame(width: 6.0, height: 6.0)
                    
                    Text(mainModel.upper.description)
                        .font(Font.system(size: 8.0))
                }
                
                HStack {
                    Image(systemName: "circle.fill")
                        .resizable()
                        .frame(width: 6.0, height: 6.0)
                    
                    Text(mainModel.lower.description)
                        .font(Font.system(size: 8.0))
                }
            }
        }
    }
    

    And then pass the model to the View when you create it:

    TestView(mainModel: self)
    

    Another similar approach would be keeping TestView the same but wrapping it in something that knows about the ObservableObject:

    struct TestViewWrapper: View {
      @ObservedObject var mainModel: MainModel
    
      var body: some View {
        TestView(upper: mainModel.upper, lower: mainModel.lower)
      }
    }
    

    You could use that strategy if you want to keep TestView isolated from the model.