In-process animations and transitions with CADisplayLink, done right
Admittedly, animation and transition techniques on macOS are not as widely applied or evolved as they may be on iOS-based platforms, and information and advice around them can be scattered and oftentimes outdated. So, let’s fix that and try to outline some best practices, as well as establish some intuition about some of the core tools available to us when working with animations.
CADisplayLink
is an unavoidable component in the vast majority of in-process animation techniques. However, so much of the existing usage, both open-source and proprietary, does not consistently and correctly manage CADisplayLink
s – at least on macOS.
In-process vs. Out-of-process
Before moving on to the discussion of using CADisplayLink
for animations on macOS (and other platforms), it feels important to provide some background on why one might choose to use it in the first place.
Out-of-process animation techniques on both macOS and iOS involve interfacing with the render server. If you’re familiar with Core Animation (or LayerKit, if you prefer), you’ve used CABasicAnimation
, CAKeyframeAnimation
, or CASpringAnimation
. These are all examples of out-of-process animations as they are run outside of your app’s process by committing your changes to your layers’ models and any explicit transactions you commit to the render server. While the animation is running, it does not block your app’s main thread. And, similarly, if your app’s main thread happens to get clogged during one of these animations, you will not experience frame drops within the animation itself. (You may still experience glitches in other areas of the app or when trying to modify or remove the animation.)
Sounds pretty neat, especially for simple use cases like fading a view or moving a button. And it most certainly is. However, one of the biggest limitations of the out-of-process animation techniques, and Core Animation in particular, is that the set of properties that can be animated, as well as the ways in which they can be animated, are both pre-defined and limited. You cannot extend the server’s behavior by e.g. telling it how to animate a custom color matrix on your custom Metal layer. Similarly, you cannot implement a fluid spring that is both interruptible and can preserve its velocity when interrupted using CASpringAnimation
alone. These behaviors are implemented outside of your app’s process and should be viewed as immutable.
These are specifically the cases where you may want to consider an in-process animation technique. Perhaps you want a fluid spring, or a customizable decay function – or you want to interpolate between 2 custom data structures. To accomplish this, you’d implement some of the implicit render server behavior inside your app’s process. You would synchronize on the display’s (or a display’s – more on that later) refresh rate, interpolate between the source and the target value using an interpolation strategy you prefer, and at each update, or tick, of the animation, you would update the value of the animated property.
In-process animation techniques typically rely on being run on the main thread, since they need to modify main thread only properties, like anything on NSView
or UIView
, or risk runtime warnings and undefined behavior. There are notable exceptions, of course – for instance, if you’re okay with using SPI, you can update CALayer
properties from a background thread – but I’ll leave that discussion outside the scope of this post. As anything that is run on the main thread, these animations will be subject to resource contention with UI framework code and your own work that is being done on the main thread. If anything is blocking the main thread and you happen to miss the commit deadline, you will start seeing frame drops. And, now that is on you to handle, since you’re owning the animation stack. How fun!
CADisplayLink
Until macOS 14, CADisplayLink
was unavailable on macOS, and the majority of clients implementing in-process animations and transitions used the CVDisplayLink
family of APIs. These were in principle the same, having the same goal, which was to allow running callbacks in sync with the display. Some typical code to get started with receiving callbacks you’d see would be something like:
CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
CVDisplayLinkSetOutputHandler(displayLink!) { (_, _, _, _, _) -> CVReturn in
animationStep()
return kCVReturnSuccess
}
CVDisplayLinkStart(displayLink!)
Similarly, for a CADisplayLink
with an Objective-C API surface you’d see something like:
let displayLink = CADisplayLink(target: target, selector: selector)
displayLink?.preferredFramesPerSecond = Config.preferredFramesPerSecond
displayLink?.add(to: .main, forMode: .common)
displayLink?.isPaused = false
In the vast majority of use cases I’ve seen, like the ones above, the handling of both CVDisplayLink
and CADisplayLink
is incomplete and therefore the animation strategies can be fundamentally flawed in certain scenarios. That FPS is hard coded!
In a better, but still very much flawed, scenario, you might see something more sane like using UIScreen.main.maximumFramesPerSecond
(with CADisableMinimumFrameDuration
Info.plist key) on iOS.
But now, for instance, imagine I’m driving a 60 Hz external display with an iPad Pro or a MacBook Pro. You can imagine much more complicated external display use cases, especially on macOS, and especially when you consider other cases like Screen Mirroring or Sidecar, which demand these updates to happen dynamically even for the same display identities. Neither of the techniques above would correctly handle even the simplest case of switching between displays, let alone some of the more conceptually complicated use cases.
Ultimately, when you want to run an animation that smoothly runs on whatever display may be connected with the maximum possible refresh rate, you have to have the following considerations in mind:
- Multiple displays with varying refresh rates may be connected at any given time.
- The refresh rate of any display can change at any point while a display link is running.
- A window in which something is being animated may be moved to another display, which may have a different refresh rate compared to the original.
- A view in which something is being animated may be reparented and moved to a different window, potentially on a different display, which may, again, have a different refresh rate.
- A view (or a window) you’re animating may leave a display while the animation is running, in which case you should suspend your display link, and resume it when the animated object appears on some display again.
AppKit to the rescue
macOS 14.0 saw the deprecation of CVDisplayLink
family of APIs in favor of the new AppKit methods: displayLink(target:selector:)
which exist on NSView
, NSWindow
, and NSScreen
instances. These methods vend an opaque CADisplayLink
, which you can customize if you wish but don’t have to. You can simply call
displayLink.add(to: .current, forMode: .common)
And start receiving callbacks on the target you’ve specified.
These AppKit-managed display links (backed by an internal _NSDisplayLink
class) will handle two very important cases:
- It will track which display your view, window, or screen belongs to, and adjust the refresh rate of the display link accordingly.
- It will automatically suspend itself if the view or window isn’t on a display.
This gives you the complete set of tools you may need in order to handle all of the cases I’ve outlined above, and therefore to achieve animations that run as smoothly as possible on whichever display they may be running on at any given time. Of course, you may still choose to manage your display link directly (though I certainly wouldn’t recommend it if you’re targeting macOS), in which case the conceptual understanding of the variables at play will be incredibly helpful in making sure your implementation handles more than just the single most obvious use case out there.
More to consider
Once you’ve figured out your display link creation and management, it’s time to actually run your animation or transition. While interpolation techniques and ways to handle retargeting or spring physics are intentionally out of scope of this post (or any single post, for that matter) to preserve its focus, there are a few more things one must consider that are direct consequences of the variable refresh rates.
First and foremost, the duration of your frame is not TimeInterval(1 / 60)
– and never has been. Once we’ve taken the time to understand display links and moved away from hard coding FPS, we must make similar adjustments to our code elsewhere. Luckily, the CADisplayLink
object we’re working with provides 3 helpful properties: timestamp
, duration
, and targetTimestamp
. Understanding the difference between these is crucial to make sure your interpolations are correct and consistent across displays.
timestamp
represents theTimeInterval
when the last frame was displayed.targetTimestamp
is the time when the next frame is scheduled the display. When you get a callback from the display link and need to calculate the interpolation step of your animation, this is what you need to use – nottimestamp
, since your job within that callback is calculating what to display next.duration
is the interval between display updates. If you’d like to query it, note that it will only be guaranteed to be valid within the display link callbacks.
In combination with CACurrentMediaTime()
, these properties provide the complete information about display updates that may be needed in order to calculate the interpolating values for your animations and transitions.