4

I'm trying to change the colour of an image with the switching of dark/light mode in an NSViewController. I'm using this code for changing the colour of the image:

- (NSImage *)image:(NSImage *)image withColour:(NSColor *)colour
{   
    NSImage *img = image.copy;
    [img lockFocus];
    [colour set];
    NSRect imageRect = NSMakeRect(0, 0, img.size.width, img.size.height);
    NSRectFillUsingOperation(imageRect, NSCompositingOperationSourceAtop);
    [img unlockFocus];
    return img;
}

I've tried calling this method from viewWillLayout

self.help1Image.image = [self image:self.help1Image.image withColour:[NSColor systemRedColor]];

but it seems the system color always returns the same RGB values.

I've also tried listening for the notification AppleInterfaceThemeChangedNotification but even in here it seems the RGB values stay the same 1.000000 0.231373 0.188235.

[[NSDistributedNotificationCenter defaultCenter] addObserverForName:@"AppleInterfaceThemeChangedNotification"
                                                             object:nil
                                                              queue:nil
                                                         usingBlock:^(NSNotification * _Nonnull note) {

                                                             NSLog(@"AppleInterfaceThemeChangedNotification");
                                                             self.help1Image.image = [self image:self.help1Image.image withColour:[NSColor systemRedColor]];

                                                             NSColorSpace *colorSpace = [NSColorSpace sRGBColorSpace];
                                                             NSColor *testColor = [[NSColor systemBlueColor] colorUsingColorSpace:colorSpace];
                                                             CGFloat red = [testColor redComponent];
                                                             CGFloat green = [testColor greenComponent];
                                                             CGFloat blue = [testColor blueComponent];
                                                             NSLog(@"%f %f %f", red, green, blue);
                                                         }];

I have the working fine in an NSButtonCell sublass and overriding layout but can't get it working in an NSViewController

Darren
  • 10,182
  • 20
  • 95
  • 162

2 Answers2

9

First, check the documentation section "Update Custom Views Using Specific Methods" here. It says:

When the user changes the system appearance, the system automatically asks each window and view to redraw itself. During this process, the system calls several well-known methods for both macOS and iOS, listed in the following table, to update your content. The system updates the trait environment before calling these methods, so if you make all of your appearance-sensitive changes in them, your app updates itself correctly.

However, there are no NSViewController methods listed in that table.

Since the view's appearance can be independent of the current or "system" appearance, the best way to react to appearance changes in your view controller is to either KVO the view's effectiveAppearance property, or to do something in [NSView viewDidChangeEffectiveAppearance].

- (void)viewDidLoad 
{
    [self addObserver:self forKeyPath:@"view.effectiveAppearance" options:0 context:nil];
}

// ...

- (void)observeValueForKeyPath:(NSString*)keyPath ofObject:(id)object change:(NSDictionary*)change context:(void*)context
{
    if ([keyPath isEqualToString:@"view.effectiveAppearance"])
    {
// ...

NSAppearance has a currentAppearance property which is independent of the system appearance, and updated by Cocoa in the methods listed above. Everywhere else, you will need to check that is correct yourself. The idiomatic way is, again, via the view's effectiveAppearance:

[NSAppearance setCurrentAppearance:someView.effectiveAppearance];

So, in your case, the following works well for me:

- (void)viewDidLoad 
{
    [super viewDidLoad];

    [self addObserver:self forKeyPath:@"view.effectiveAppearance" options:0 context:nil];
}

-(void)viewDidLayout
{
    self.help1Image.image = [self image:self.help1Image.image withColour:[NSColor systemRedColor]];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if ([keyPath isEqualToString:@"view.effectiveAppearance"])
    {
                [NSAppearance setCurrentAppearance:self.view.effectiveAppearance];

                self.help1Image.image = [self image:self.help1Image.image withColour:[NSColor systemRedColor]];
    }
}
TheNextman
  • 12,428
  • 2
  • 36
  • 75
  • 3
    Great answer, thank you. The key here was `[NSAppearance setCurrentAppearance:self.view.effectiveAppearance];` I can set that in the notification I posted in my question and the colours are correct after that. – Darren Jul 15 '19 at 10:23
  • setCurrentAppearance has been deprecated since macOS 11. Has anyone found an alternative solution? – tipa Dec 03 '22 at 09:25
0

lets assume you have an NSScrollView with some subviews residing in such ScrollViews contentView and you are able to subclass your NSScrollView..

.. then you do not need to implement a KVO pattern even tho that works.

for such NSScrollView subclass you would simply implement..

-(void)viewDidChangeEffectiveAppearance {
    [self.contentView.subviews makeObjectsPerformSelector:@selector(viewDidChangeEffectiveAppearance)];
}

which will forward/perform all subviews -viewDidChangeEffectiveAppearance method accordingly. So for those subviews implement ...

-(void)viewDidChangeEffectiveAppearance {
    NSString *schemeName = self.window.effectiveAppearance.name;
    if ([schemeName containsString:@"Dark"]) {
        //set colors for Dark Scheme here..
    } else {
        //set colors for Aqua Scheme here..
        //or [schemeName containsString:@"Vibrant"];
        //or [schemeName containsString:@"HighContrast"];
    }
} 

-viewDidChangeEffectiveAppearance is not always triggered to the last subview when you have a custom drawing cycle. In example when you avoided the use of -drawRect method, most likely when you did build your graphics out of CALayers and like-like. Or it is called on each found ViewController's subview (not tested). That said, the documentation is pretty empty, at least one should know that this method solely exists to avoid @selector failures, which in turn allows you to call
-makeObjectsPerformSelector:@selector(viewDidChangeEffectiveAppearance) on NSView's subviews Arrays. Not really sure why Apple choose a fixed method instead of a protocol, most likely to avoid conformsToProtocol and respondsToSelector calls prior the effective call.

Also note inside the default predefined -viewDidChangeEffectiveAppearance method you will ask for self.window.effectiveAppearance. Why not asking for NSAppearance.currentAppearance? Because NSAppearance.currentAppearance will not reflect the change unless your system really changed but self.window.effectiveAppearance will deliver the right scheme in use.

The solution above has the benefit to circumvent the setCurrentAppearance deprecation. And you are responsible to cascade those calls to your subviews, in particular when you implemented your subviews programmatically and not with InterfaceBuilder

Ol Sen
  • 3,163
  • 2
  • 21
  • 30