13
votes

I have a custom layer-backed NSView and have overidden the makeBackingLayer method to return a custom CALayer subclass. I have also overriden wantsUpdateLayer to return true thereby fully opting into layer-like drawing.

override func makeBackingLayer() -> CALayer {
    return Layer() // my custom class with "drawLayer:" implemented
}

override var wantsUpdateLayer:Bool{
    return true
}

// never called
override func updateLayer() {
    super.updateLayer()
    println("updateLayer after")
    self.layer?.borderWidth += 1
}

Once I do this, I find that when I set NSView.needsDisplay = true it routes calls to the custom layer's drawInContext: method as opposed to the updateLayer: method. Why does it do this? In my example, I have checked that if I remove the makeBackingLayer override then my updateLayer is called in the expected manner.

I can't quite put my finger on it, but other instances point to the notion that when your makeBackingLayerreturns a customCALayer` that you actually have your custom layer hosted inside a parent backing layer. (Pure Speculation on my part)

Further, would there be different performance characteristics between the two drawing routes given that CALayer's drawInContext: is more "low-level"? See this SO question for more detail on that question: Layer-backed NSView performance with rendering directly in CALayer.drawInContext:

Any insight would be greatly appreciated.

3

3 Answers

4
votes

Surely you fixed it by now, but for future readers I found a possible explanation by reading the NSView documentation!

If the canDrawSubviewsIntoLayer property is set to YES, the view ignores the value returned by this method. Instead, the view always uses its drawRect: method to draw its content.

1
votes

Did you forget to set the layerContentsRedrawPolicy and wantsLayer properties?

self.wantsLayer = YES;
self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawPolicy.OnSetNeedsDisplay;
1
votes

When you override makeBackingLayer, it becomes your responsibility to set up that layer, including setting its delegate: CALayerDelegate. It's enough to set the delegate to the view using that layer. From there, you can implement any of those delegate methods, though you probably want func display(_ layer: CALayer). See Core Animation Guide - Providing a Layer's Contents for more on this.

The code path for updateLayer depends on the NSView using the default layer type. Here is my working NSView subclass that redraws on bounds change:

/**
 A view backed by a CAShapeLayer that can inscribe an image inside the
 shape layer's circular path
 */  
@IBDesignable final class InscribedImageView: NSView, CALayerDelegate {

    @IBInspectable private var image: NSImage?
    @IBInspectable private var color: NSColor = .clear

    // swiftlint:disable:next force_cast
    private var shapeLayer: CAShapeLayer { return self.layer as! CAShapeLayer }

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        configureViewSetting()
    }

    required init?(coder decoder: NSCoder) {
        super.init(coder: decoder)
        configureViewSetting()
    }

    private func configureViewSetting() {
        wantsLayer = true
    }

    override func makeBackingLayer() -> CALayer {
        let layer = CAShapeLayer()
        layer.delegate = self
        layer.needsDisplayOnBoundsChange = true
        return layer
    }

    func display(_ layer: CALayer) {
        inscribe(image, in: color)
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        inscribe(image, in: color)
    }

    func inscribe(_ image: NSImage?, in color: NSColor) {
        self.color = color
        defineCircle(color: color)
        subviews.forEach { $0.removeFromSuperview() }
        if let image = image {
            self.image = image
            let imageView = NSImageView(image: image)
            imageView.imageScaling = .scaleProportionallyUpOrDown
            imageView.frame = bounds
            addSubview(imageView)
        }
    }

    private func defineCircle(color: NSColor) {
        shapeLayer.path = CGPath(ellipseIn: shapeLayer.bounds, transform: nil)
        shapeLayer.fillColor = color.cgColor
        shapeLayer.strokeColor = color.cgColor
    }
}

Note that I didn't need to set wantsUpdateLayer to return true on my subclass OR the subclass's layerContentsRedrawPolicy = LayerContentsRedrawPolicy.duringViewResize. Presumably this is because both rely on the stock layer type, not a subclass.

Here are some more useful resources from my previous, incorrect answer:

Apple's Core Animation Guide

Overriding wantsUpdateLayer and returning YES causes the NSView class to follow an alternate rendering path. Instead of calling drawRect:, the view calls your updateLayer method, the implementation of which must assign a bitmap directly to the layer’s contents property. This is the one scenario where AppKit expects you to set the contents of a view’s layer object directly.

Also in NSView.h, the docs for updateLayer are

/* Layer Backed Views: If the view responds YES to wantsUpdateLayer, then updateLayer will be called as opposed to drawRect:. This method should be used for better performance; it is faster to directly set the layer.contents with a shared image and inform it how to stretch with the layer.contentsCenter property instead of drawing into a context with drawRect:. In general, one should also set the layerContentsRedrawPolicy to an appropriate value in the init method (frequently NSViewLayerContentsRedrawOnSetNeedsDisplay is desired). To signal a refresh of the layer contents, one will then call [view setNeedsDisplay:YES], and -updateLayer will be lazily called when the layer needs its contents. One should not alter geometry or add/remove subviews (or layers) during this method. To add subviews (or layers) use -layout. -layout will still be called even if autolayout is not enabled, and wantsUpdateLayer returns YES. */