2

enter image description here

I have a popover as seen from the image.

I have to make sure that when the screen mode changes, dark mode or light mode, the color of the popover changes.

The color is taken from the asset, like this:

NSColor(named: "backgroundTheme")?.withAlphaComponent(1)

enter image description here

As you can see from the code when starting the popover in the init function I assign the color accordingly.

How can I intercept the change of mode?

Can you give me a hand?

AppDelegate:

import Cocoa
import SwiftUI

@main
class AppDelegate: NSObject, NSApplicationDelegate {
    var popover = NSPopover.init()
    var statusBar: StatusBarController?
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let contentView = ContentView()
        popover.contentSize = NSSize(width: 560, height: 360)
        popover.contentViewController = NSHostingController(rootView: contentView)
        statusBar = StatusBarController.init(popover)
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }
}

StatusBarController:

import AppKit
import SwiftUI

extension NSPopover {
    
    private struct Keys {
        static var backgroundViewKey = "backgroundKey"
    }
    
    private var backgroundView: NSView {
        let bgView = objc_getAssociatedObject(self, &Keys.backgroundViewKey) as? NSView
        if let view = bgView {
            return view
        }
        
        let view = NSView()
        objc_setAssociatedObject(self, &Keys.backgroundViewKey, view, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        NotificationCenter.default.addObserver(self, selector: #selector(popoverWillOpen(_:)), name: NSPopover.willShowNotification, object: nil)
        return view
    }
    
    @objc private func popoverWillOpen(_ notification: Notification) {
        if backgroundView.superview == nil {
            if let contentView = contentViewController?.view, let frameView = contentView.superview {
                frameView.wantsLayer = true
                backgroundView.frame = NSInsetRect(frameView.frame, 1, 1)
                backgroundView.autoresizingMask = [.width, .height]
                frameView.addSubview(backgroundView, positioned: .below, relativeTo: contentView)
            }
        }
    }
    
    var backgroundColor: NSColor? {
        get {
            if let bgColor = backgroundView.layer?.backgroundColor {
                return NSColor(cgColor: bgColor)
            }
            return nil
        }
        set {
            backgroundView.wantsLayer = true
            backgroundView.layer?.backgroundColor = newValue?.cgColor
        }
    }
}

class StatusBarController {
    private var popover: NSPopover
    private var statusBar: NSStatusBar
    var statusItem: NSStatusItem

    
    init(_ popover: NSPopover) {
        self.popover = popover
        self.popover.backgroundColor = NSColor(named: "backgroundTheme")?.withAlphaComponent(1)
        statusBar = NSStatusBar.init()
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        
        if let statusBarButton = statusItem.button {
            statusBarButton.image = #imageLiteral(resourceName: "Fork")
            statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0)
            statusBarButton.image?.isTemplate = true
            statusBarButton.action = #selector(togglePopover(sender:))
            statusBarButton.target = self
            statusBarButton.imagePosition = NSControl.ImagePosition.imageLeft
        }
    }
    
    @objc func togglePopover(sender: AnyObject) {
        if(popover.isShown) {
            hidePopover(sender)
        }else {
            showPopover(sender)
        }
    }
    
    func showPopover(_ sender: AnyObject) {
        if let statusBarButton = statusItem.button {
            popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY)
        }
    }
    
    func hidePopover(_ sender: AnyObject) {
        popover.performClose(sender)
    }
    
}
Paul
  • 3,644
  • 9
  • 47
  • 113
  • 1
    Does this answer your question? [How to detect switch between macOS default & dark mode using Swift 3](https://stackoverflow.com/questions/39048894/how-to-detect-switch-between-macos-default-dark-mode-using-swift-3) – El Tomato Aug 11 '21 at 12:23
  • @ElTomato: It seems to work, but inside interfaceModeChanged I can't get the backgroundTheme color back when I change. `popover.backgroundColor = NSColor(named:" backgroundTheme")?.withAlphaComponent(1)` – Paul Aug 11 '21 at 13:14
  • In macOS, observing the appearance change takes two steps. You can see the current appearance (aqua or dark) within a View (SwiftUI) or a view controller (Cocoa). That's not enough. You also need to let the application observe the appearance change through `AppDelegate` so that it will know when the user changes it with System Preferences. – El Tomato Aug 11 '21 at 13:37
  • @ElTomato: So how can I fix the problem? – Paul Aug 11 '21 at 13:48
  • "backgroundView.layer?.backgroundColor = newValue?.cgColor" -> you shouldn't set layer color inside color setter. Use setNeedsUpdatelayer and do your layer color assignment in update layer. I recommend watching WWDC18 Advanced Dark Mode where this is mentioned why your code doesn;t work https://developer.apple.com/videos/play/wwdc2018/218/ – Marek H Aug 11 '21 at 16:09
  • @MarekH: So you are telling me this answer is wrong: https://stackoverflow.com/a/68445051/8024296 – Paul Aug 12 '21 at 10:21
  • @Paul I don't think the popover reacts to light/dark mode change (when it's open and there is a sudden change). It's explained in WWDC how to do layer updating correctly. – Marek H Aug 12 '21 at 14:04

2 Answers2

1

Hi I would skip setting the color on the popover and instead set the background in your ContentView.swift

Then set the background to a VStack/HStack/ZStack wrapping the rest of the UI.

var body: some View {
        VStack{
            Text("Hello, world!").padding()
            Button("Ok", action: {}).padding()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color("backgroundTheme").opacity(0.3))
        .padding(.top, -16)
    }

enter image description here enter image description here

Niklas
  • 35
  • 8
  • I've already done this, but it doesn't work because the arrow would have a different color and if you use transparency it's even worse. – Paul Aug 13 '21 at 09:05
  • Ah, I read another question of yours just before this. Where you were asking how to turn off the arrow from the popover so thought you didn't have it anymore. – Niklas Aug 13 '21 at 09:23
  • @Paul I just edited my code above, this should help you out. Little hack... But hey, it works ;) – Niklas Aug 13 '21 at 09:31
  • @Paul please consider accepting and upvoting my answer if it solved your question. :) – Niklas Aug 16 '21 at 10:47
1

There are something to keep in mind:

  • Methods like .withAlphaComponent(_:) that transform a existing NSColor to a new color does not return a dynamic color.

  • CGColor is not dynamic-capable. When converting a NSColor to a CGColor using .cgColor, you are converting from the "current" color of a NSColor.

So your hacky way is not really a good approach to what you want.


Base on what you had said, if I understand correctly, you want to add a color overlay to the popover's background, including the arrow portion.

You can actually do all that in a view controller:

class PopoverViewController: NSViewController {
    /// for color overlay
    lazy var backgroundView: NSBox = {
        // 1. This extend the frame to cover arrow potion.
        let box = NSBox(frame: view.bounds.insetBy(dx: -13, dy: -13))
        box.autoresizingMask = [.width, .height]
        box.boxType = .custom
        box.titlePosition = .noTitle
        box.fillColor = NSColor(named: "backgroundTheme")
        return box
    }()
    
    /// for mounting SwiftUI views
    lazy var contentView: NSView = {
        let view = NSView(frame: view.bounds)
        view.autoresizingMask = [.width, .height]
        return view
    }()
    
    override func loadView() {
        view = NSView()
        
        // 2. This avoid clipping.
        view.wantsLayer = true
        view.layer?.masksToBounds = false

        view.addSubview(backgroundView)
        view.addSubview(contentView)
    }
}

Pay attention to 1 and 2, this allow backgroundView to draw beyond view's bounds, covering the arrow portion. backgroundView is a NSBox object that accept a dynamic NSColor object to style its background.

Notice that if you want to change the opacity of the color, instead of .withAlphaComponent(_:), change the opacity on your assets, right below the RGB sliders.

enter image description here

contentView is here as a mounting point for your SwiftUI views. To mount content from a NSHostingController, you can do:

let popoverViewController = PopoverViewController()
_ = popoverViewController.view // this trigger `loadView()`, you don't need this for auto layout

let hostingController = NSHostingController(rootView: ContentView())
hostingController.view.frame = popoverViewController.contentView.bounds
hostingController.view.autoresizingMask = [.width, .height]

popoverViewController.contentView.addSubview(hostingController.view)
popoverViewController.addChild(hostingController)

This add hostingController's view as a subview of popoverViewController's contentView.

That's it.

enter image description here

Do note that I use autoresizingMask instead of auto layout and extract the mounting part out of the PopoverViewController to simply my answer.

ix4n33
  • 526
  • 4
  • 10