Search code examples
cocoaanimationcore-animation

Why is an empty drawRect interfering with a simple animation?


I'm trying to put together my own take on Cocoa's NSPathControl.

enter image description here

In a bid to figure out the best way to handle the expand-contract animation that you get when you mouse over a component in the control, I put together a very simple sample app. Initially things were going well - mouse into or out of the view containing the path components and you'd get a simple animation using the following code:

// This code belongs to PathView (not PathComponentView)

override func mouseEntered(theEvent: NSEvent) {

    NSAnimationContext.runAnimationGroup({ (ctx) -> Void in
        self.animateToProportions([CGFloat](arrayLiteral: 0.05, 0.45, 0.45, 0.05))
    }, completionHandler: { () -> Void in
            //
    })
}

// Animating subviews

func animateToProportions(proportions: [CGFloat]) {

    let height = self.bounds.height
    let width = self.bounds.width

    var xOffset: CGFloat = 0
    for (i, proportion) in enumerate(proportions) {
        let subview = self.subviews[i] as! NSView
        let newFrame = CGRect(x: xOffset, y: 0, width: width * proportion, height: height)
        subview.animator().frame = newFrame
        xOffset = subview.frame.maxX
    }
}

As the next step in the control's development, instead of using NSView instances as my path components I started using my own NSView subclass:

class PathComponentView: NSView {

    override func drawRect(dirtyRect: NSRect) {
        super.drawRect(dirtyRect)
    }
}

Although essentially identical to NSView, when I use this class in my animation, the animation no longer does what it's supposed to - the frames I set for the subviews are pretty much ignored, and the whole effect is ugly. If I comment out the drawRect: method of my subclass things return to normal. Can anyone explain what's going wrong? Why is the presence of drawRect: interfering with the animation?

I've put a demo project on my GitHub page.


Solution

  • You've broken some rules here, and when you break the rules, behavior becomes undefined.

    In a layer backed view, you must not modify the layer directly. See the documentation for wantsLayer (which is how you specify that it's layer-backed):

    In a layer-backed view, any drawing done by the view is cached to the underlying layer object. This cached content can then be manipulated in ways that are more performant than redrawing the view contents explicitly. AppKit automatically creates the underlying layer object (using the makeBackingLayer method) and handles the caching of the view’s content. If the wantsUpdateLayer method returns NO, you should not interact with the underlying layer object directly. Instead, use the methods of this class to make any changes to the view and its layer. If wantsUpdateLayer returns YES, it is acceptable (and appropriate) to modify the layer in the view’s updateLayer method.

    You make this call:

    subview.layer?.backgroundColor = color.CGColor
    

    That's "interact[ing] with the underlying layer object directly." You're not allowed to do that. When there's no drawRect, AppKit skips trying to redraw the view and just uses Core Animation to animate what's there, which gives you what you expect (you're just getting lucky there; it's not promised this will work).

    But when it sees you actually implemented drawRect (it knows you did, but it doesn't know what's inside), then it has to call it to perform custom drawing. It's your responsibility to make sure that drawRect fills in every pixel of the rectangle. You can't rely on the backing layer to do that for you (that's just a cache). You draw nothing, so what you're seeing is the default background gray intermixed with cached color. There's no promise that all of the pixels will be updated correctly once we've gone down this road.

    If you want to manipulate the layer, you want a "layer-hosting view" not a "layer-backed view." You can do that by replacing AppKit's cache layer with your own custom layer.

    let subview = showMeTheProblem ? PathComponentView() : NSView()
    addSubview(subview)
    subview.layer = CALayer() // Use our layer, not AppKit's caching layer.
    subview.layer?.backgroundColor = color.CGColor
    

    While it probably doesn't matter in this case, keep in mind that a layer-backed view and a layer-hosting view have different performance characteristics. In a layer-backed view, AppKit is caching your drawRect results into its caching layer, and then applying animations on that fairly automatically (this allows existing AppKit code that was written before CALayer to get some nice opt-in performance improvements without changing much). In a layer-hosting view, the system is more like iOS. You don't get any magical caching. There's just a layer, and you can use Core Animation on it.