objective-ccalayernsimage

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


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?


Solution

  • 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