How to make a CALayer with NSImage dynamically adapt to the app appearance?

120 Views Asked by At

I have an NSView whose layer has various sublayers.

Its needsDisplay property is set to YES when the app changes its effective appearance, which ends up calling:

-(void)updateLayer {
       // this is a simplified version
        sublayer1.backgroundColor = NSColor.windowBackgroundColor.CGColor;
        sublayer2.content = [NSImage imageNamed:@"dynamic image"];
       // "dynamic image" is an image asset that has light and dark variants.
}

Both layers render properly according to the theme after the app launches, but when the app changes theme (without being relaunched), the appearance of sublayer2 does not change. The appearance of sublayer1 does change, but I have to relaunch the app for sublayer2 to update to the app theme. Using "dynamic image" as template image or original image has no effect on this issue.

Note that the same image used in an NSButton does change appearance properly without having to relaunch the app.

Any suggestion?

1

There are 1 best solutions below

0
DonMag On BEST ANSWER

From Apple's docs for CALayer.contents:

Discussion

The default value of this property is nil.

If you are using the layer to display a static image, you can set this property to the CGImageRef containing the image you want to display. (In macOS 10.6 and later, you can also set the property to an NSImage object.) Assigning a value to this property causes the layer to use your image rather than create a separate backing store.

If the layer object is tied to a view object, you should avoid setting the contents of this property directly. The interplay between views and layers usually results in the view replacing the contents of this property during a subsequent update.

So, when setting:

sublayer2.content = [NSImage imageNamed:@"dynamic image"];

the layer gets a CGImageRef, not a NSImage ... and appearance changes will have no effect.

One approach would be to replace the CALayer with a NSImageView as a subview. Set the .image property, and you're done.

If you need to use CALayer, we need to get the CGImageRef from the image with the current appearance in updateLayer.

Quick example:

LayeredView.h

#import <Cocoa/Cocoa.h>

NS_ASSUME_NONNULL_BEGIN
@interface LayeredView : NSView
@end
NS_ASSUME_NONNULL_END

LayeredView.m

#import "LayeredView.h"
#import <Quartz/Quartz.h>

@interface LayeredView()
{
    CALayer *imgLayer;
    NSImage *img;
}
@end

@implementation LayeredView

- (id)initWithFrame:(NSRect)frame {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    self = [super initWithFrame:frame];
    if (self) {
        [self commonInit];
    }
    return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    self = [super initWithCoder:coder];
    if (self) {
        [self commonInit];
    }
    return self;
}
- (void)commonInit {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    [self setWantsLayer:YES];
    
    // load the image
    img = [NSImage imageNamed:@"i100x100"];
    
    // create layer
    imgLayer = [CALayer new];
    
    // .contentsGravity as desired
    imgLayer.contentsGravity = kCAGravityCenter;
    
    // add the layer
    [self.layer addSublayer:imgLayer];
}
- (void)updateLayer {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    [super updateLayer];
    
    // built-in layer animation will result in a "fade"
    //  so disable it (if desired)
    [CATransaction begin];
    [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
    
    if (img) {
        // get the CGImageRef from img -- this will reflect light/dark mode
        imgLayer.contents = (__bridge id _Nullable)([img CGImageForProposedRect:nil context:nil hints:nil]);
    }

    // image layer frame as desired
    imgLayer.frame = self.bounds;

    [CATransaction commit];
}
@end