Implementing Physics-based Movement for UIKit/AppKit Components
- #UI/UX
• 16 min read
Introduction
Apple provides developers with various options for creating UI animations.
We've got our UIView.animate(...)
method on iOS and NSAnimationContext.runAnimationGroup(...)
method on macOS for animating simple things, like layout constraints or layer properties.
The CoreAnimation framework allows us to perform even more complex animations on CALayer
instances with various available transformations and CAAnimation
subclasses.
That's basically everything we need to animate a UI component from state A to state B.
However, things get much more complicated if we want our animations to continuously update in real time in reaction to the user's input. Imagine implementing this with the tools we just mentioned:
You may be considering applying a CASpringAnimation
to a layer right now, but that won't work, and we'll show you why in a minute.
What you see here is indeed a spring animation, but this one is entirely custom, based on real-world physics equations, and can be applied to layers, views, and even macOS windows.
Sounds interesting?
Below is a journey to implementing physics-based movement for any UI component on macOS/iOS. The final implementation is publicly available in the CocoaSprings repository; feel free to skip right to it for the solution.
The window moving conundrum
In the Technological R&D department at MacPaw, we've been busy implementing an app prototype with a high emphasis on experimental UI.
The app's user interface comprises a main NSWindow
with a few smaller supplementary windows attached. For the sake of simplicity and NDA, let's imagine it looks something like this:
Here's the code of the MainViewController
(the controller of the main window's view) with basic windows setup and positioning:
import Cocoa
final class MainViewController: NSViewController {
// SupplementaryWindow is an NSWindow subclass with minor styling changes, not relevant for now.
private lazy var leftSupplementaryWindow = SupplementaryWindow()
private lazy var rightSupplementaryWindow = SupplementaryWindow()
override func viewDidAppear() {
super.viewDidAppear()
setupSupplementaryWindows()
}
}
// MARK: - Private
private extension MainViewController {
func setupSupplementaryWindows() {
guard let mainWindow = view.window else { return }
// Position supplementary windows on the left and right side of the main window.
let leftWindowSize = leftSupplementaryWindow.frame.size
let leftWindowFrame = NSRect(
origin: .init(x: mainWindow.frame.minX - leftWindowSize.width - 10,
y: mainWindow.frame.midY - leftWindowSize.height / 2),
size: leftWindowSize
)
leftSupplementaryWindow.setFrame(leftWindowFrame, display: true)
leftSupplementaryWindow.makeKeyAndOrderFront(nil)
let rightWindowSize = rightSupplementaryWindow.frame.size
let rightWindowFrame = NSRect(
origin: .init(x: mainWindow.frame.maxX + 10,
y: mainWindow.frame.midY - rightWindowSize.height / 2),
size: rightWindowSize
)
rightSupplementaryWindow.setFrame(rightWindowFrame, display: true)
rightSupplementaryWindow.makeKeyAndOrderFront(nil)
}
}
Basic animation
We need our supplementary windows to follow the main window when the user moves it. This can be easily done by setting small windows as children of the main one, and macOS will handle the rest:
mainWindow.addChildWindow(leftSupplementaryWindow, ordered: .above)
mainWindow.addChildWindow(rightSupplementaryWindow, ordered: .above)
Works great. Looks dull.
We'd like our windows to "fly over" to their positions next to the main window as if the main window was pulling them.
Let's try a simple approach and update their position with the default NSWindow
frame animation.
Presume that the supplementary windows are no longer children of the main window, and we're now observing the main window frame's changes to call setFrame(..., animate: true)
for the small windows.
In theory, this should smooth out the windows' movement.
Well, that won't work either. The animation is glitchy and surely isn't supposed to be called so frequently. And even if it did work, we are not huge fans of the somewhat linear way the windows move with it. We'll need a bit more ingenuity for this to work.
CALayer solution: the wrong but insightful one
There's a known, though a bit cumbersome, solution to implementing custom window animations in macOS:
- Make a transparent borderless window with a layer-hosting view and cover the user's screen with it.
- When there is a need to animate a window, screenshot it and place the resulting image as a
CALayer
into the transparent overlay window. Hide the original window. - Animate the screenshot layer's position/opacity/whatever with a
CAAnimation
. - Hide the screenshot layer and restore the window when you're done.
Aside from being quite bloated in implementation, this method has other drawbacks, like the animated screenshot layer won't reflect any changes to the actual window's content while the animation is running. Also, if you use visual effects for the window's background, like in our example app, the screenshot will make the background opaque. Nevertheless, we decided to try this approach anyway — and iron out the rest of the issues later if the animation works.
We'll screenshot our supplementary windows and move them with CASpringAnimation
— it should provide us with the "pulling" effect we desire.
The fromValue
of the animation is the position of the presentation copy of the layer when the main window updates its frame; the toValue
is the supplementary window's destination point near the main window.
We won't post the solution here to keep it brief, but here's an example of this approach if you're interested: JNWAnimatableWindow.
Yay, it works! But hold on... did you notice that stuttering? Look closely, we'll move the window slower this time.
Every time the main window updates its frame, we replace a screenshot's running CASpringAnimation
with the new one containing the relevant fromValue
/toValue
coordinates.
And obviously, the new animation has no concept of the current velocity of the layer, thus restarting the motion.
It might not be too noticeable if we drag the window fast, but it definitely doesn't look good if we do it slowly.
Can we fix that speed drop?
Well, CASpringAnimation
does have an initialVelocity
property, and it sounds like that's just what we need — we can set some value there to keep the motion going each time we restart an animation.
Alas, there's no way of calculating the animation velocity in a given moment of time without knowing how Apple developers implemented the algorithm behind it.
What we know for sure now, though, is that spring animation is the way to go. We'll need a better understanding of how these animations work under the hood to keep track of a moving object's velocity. And while we're at it, we might as well program the movement ourselves.
Before we continue, though, we must stress that if the animation you're trying to implement can be done with basic CA layers and animations — stick to them. What we'll be doing next is much more resource-demanding and will negatively impact your app's performance if used excessively. You may end up overloading the main thread with lots of extra calculations.
With that out of the way, let's proceed.
The animation algorithm
We need to get ahold of an algorithm that calculates spring physics. If we control the implementation, we may also determine the object's movement velocity at any given moment and achieve continuity of motion even if its destination rapidly changes.
After spending some time researching the topic, we stumbled upon this excellent article by Ryan Juckett. In it, he describes the math behind the algorithm he uses for moving third-person cameras in video games. The camera motion fits our requirements just right: it is based on spring physics and smoothly adapts to any abrupt movements the player can make. Nothing stops us from applying the same logic to move a UI object on a Mac screen.
Now, we won't even try to cover the math behind Ryan's algorithm, nor do we need to — he's done a great job explaining it himself. What's great is he has also provided an implementation of his algorithm in the C programming language. Let's adapt it for our use in Swift.
Programming spring physics
We'll start by defining the algorithm input parameters:
angluarFrequency
— defines how fast an object should move to its destination. The higher the value, the faster an object moves.dampingRatio
— defines how fast the motion decays. The lower the value, the less velocity is lost upon the object reaching its destination. The article mentions that different calculations should be performed for cases when0 < dampingRatio < 1
,dampingRatio == 1
, anddampingRatio > 1
. We'll settle with just the former case for now; it's more than enough for our needs.timeStep
— the number of seconds between each motion step.
The latter, timeStep
, is quite technical and shouldn't be determined by the client that simply wants to configure a UI component's animation.
We'll deal with it later.
For now, let's group the other two parameters into a SpringConfiguration
struct and define the default values for easier use:
struct SpringConfiguration {
var angularFrequency: Float
var dampingRatio: Float
static let `default` = Self(angularFrequency: 4.0, dampingRatio: 0.5)
}
We'll also need to keep track of the state of motion, namely its position coordinates and velocity.
Here's the SpringMotionState
class for that:
final class SpringMotionState {
struct Velocity {
let horizontal: Double
let vertical: Double
static let zero = Self(horizontal: 0, vertical: 0)
}
let position: CGPoint
let velocity: Velocity
init(position: CGPoint, velocity: Velocity) {
self.position = position
self.velocity = velocity
}
}
Next, we'll encapsulate all the math behind spring physics into a separate class. We'll pass the mentioned earlier physics parameters on init — they will be used immediately to define and cache a set of coefficients required to calculate every step of an object's movement. We'll also add two public methods:
calculateNextState(from state:destinationPoint:)
— takes the currentstate
of motion and returns the next state on the way todestinationPoint
.calcualteAllStates(from initialState:destinationPoint)
— will return an array of all motion states starting with theinitialState
and all the way to when an object stops moving at itsdestinationPoint
.
final class SpringMotionPhysics {
private let posPosCoef: Double
private let posVelCoef: Double
private let velPosCoef: Double
private let velVelCoef: Double
init(configuration: SpringConfiguration, timeStep: Float) {
let c = configuration
let omegaZeta = c.angularFrequency * c.dampingRatio
let alpha = c.angularFrequency * sqrtf(1.0 - c.dampingRatio * c.dampingRatio)
let expTerm = expf(-omegaZeta * timeStep)
let cosTerm = cosf(alpha * timeStep)
let sinTerm = sinf(alpha * timeStep)
let invAlpha = 1.0 / alpha
let expSin = expTerm * sinTerm
let expCos = expTerm * cosTerm
let expOmegaZetaSin_Over_Alpha = expTerm * omegaZeta * sinTerm * invAlpha
posPosCoef = Double(expCos + expOmegaZetaSin_Over_Alpha)
posVelCoef = Double(expSin * invAlpha)
velPosCoef = Double(-expSin * alpha - omegaZeta * expOmegaZetaSin_Over_Alpha)
velVelCoef = Double(expCos - expOmegaZetaSin_Over_Alpha)
}
func calculateNextState(from state: SpringMotionState, destinationPoint: CGPoint) -> SpringMotionState {
let relPos = state.position - destinationPoint
let horVelCoef = state.velocity.horizontal * posVelCoef
let verVelCoef = state.velocity.vertical * posVelCoef
let xCoef = relPos.x * posPosCoef
let yCoef = relPos.y * posPosCoef
return SpringMotionState(
position: .init(x: xCoef + horVelCoef + destinationPoint.x,
y: yCoef + verVelCoef + destinationPoint.y),
velocity: .init(horizontal: (relPos.x * velPosCoef) + (state.velocity.horizontal * velVelCoef),
vertical: (relPos.y * velPosCoef) + (state.velocity.vertical * velVelCoef))
)
}
func calculateAllStates(from initialState: SpringMotionState, destinationPoint: CGPoint) -> [SpringMotionState] {
var currentState = initialState
var allStates = [SpringMotionState]()
var shouldContinue = true
while shouldContinue {
let nextState = calculateNextState(from: currentState, destinationPoint: destinationPoint)
guard abs(nextState.velocity.horizontal) > 0.001 || abs(nextState.velocity.vertical) > 0.001 else {
shouldContinue = false
continue
}
allStates.append(nextState)
currentState = nextState
}
return allStates
}
}
This looks good.
We can now use an instance of SpringMotionPhysics
to calculate spring motion with calculateNextState(...)
on the fly and move a UI component one step at a time.
Or we can calculate all the motion states with calculateAllStates(...)
, send an object on its way, and forget about it.
Most importantly, these calculations are relatively cheap on CPU resources because we've done all the expensive math operations on init by caching those coefficients. Nevertheless, as mentioned before, one should avoid getting greedy with this logic: simultaneously calculating motion for lots of objects in the main thread will definitely slow down the computer and drain the battery. See the Performance section of this article for more info.
We will now return to our example app and make those windows fly.
Applying physics to windows
The idea here is to manually update the frame
of supplementary windows based on the positions we'll get from SpringMotionPhysics
in the form of SpringMotionState
objects.
We must do this very frequently so that the animation runs smoothly, and CVDisplayLink
/CADisplayLink
on macOS/iOS, respectively, are designed just for that.
With their help, we can synchronize the movement logic with the display refresh rate.
We can also get the current numeric value for the FPS rate from them, which translates nicely into the timeStep
parameter we need for a SpringMotionPhysics
instance.
Below is the updated code: MainViewController
triggers movement for the SupplementaryWindow
instances when the main window moves.
The small windows are responsible for moving themselves, MainViewController
only provides their destination point and spring configuration.
MainViewController
final class MainViewController: NSViewController {
private lazy var leftSupplementaryWindow = SupplementaryWindow()
private lazy var rightSupplementaryWindow = SupplementaryWindow()
private var mainWindowFrameObservation: NSKeyValueObservation?
override func viewDidAppear() {
super.viewDidAppear()
setupSupplementaryWindows()
observeMainWindowFrame()
}
}
// MARK: - Private
private extension MainViewController {
func setupSupplementaryWindows() {
leftSupplementaryWindow.configuration = .init(angularFrequency: 4.0, dampingRatio: 0.6)
leftSupplementaryWindow.makeKeyAndOrderFront(nil)
rightSupplementaryWindow.configuration = .init(angularFrequency: 3.5, dampingRatio: 0.5)
rightSupplementaryWindow.makeKeyAndOrderFront(nil)
}
func observeMainWindowFrame() {
guard let mainWindow = view.window else { return }
mainWindowFrameObservation = mainWindow.observe(\.frame) { [weak self] _,_ in
guard let self else { return }
self.leftSupplementaryWindow.move(to: .init(
x: mainWindow.frame.minX - self.leftSupplementaryWindow.frame.width / 2 - 10,
y: mainWindow.frame.midY
))
self.rightSupplementaryWindow.move(to: .init(
x: mainWindow.frame.maxX + self.rightSupplementaryWindow.frame.width / 2 + 10,
y: mainWindow.frame.midY
))
}
}
}
SupplementaryWindow
final class SupplementaryWindow: NSWindow {
// MARK: Public
/// Configuration that adjusts the spring physics behind the animation.
var configuration: SpringConfiguration = .default
/// Moves the window to the specified point with spring animation.
///
/// - Parameter point: a `CGPoint` that defines the window's destination and represents the window's center point.
func move(to point: NSPoint) {
destinationPoint = point
}
// MARK: Private
private var destinationPoint: NSPoint? {
didSet {
startMotion()
}
}
private var motionPhysics: SpringMotionPhysics?
private var currentMotionState: SpringMotionState?
private var displayLink: CVDisplayLink?
}
// MARK: - Private methods
private extension SupplementaryWindow {
func startMotion() {
guard displayLink == nil,
let screenDescription = NSScreen.main?.deviceDescription,
let screenNumber = screenDescription[.init("NSScreenNumber")] as? NSNumber else { return }
CVDisplayLinkCreateWithCGDisplay(screenNumber.uint32Value, &displayLink)
guard let displayLink else { return }
CVDisplayLinkSetOutputHandler(displayLink) { [weak self] _, inNow, inOutputTime, _, _ in
let inNowInterval = TimeInterval(inNow.pointee.videoTime) / TimeInterval(inNow.pointee.videoTimeScale)
let inOutputInterval = TimeInterval(inOutputTime.pointee.videoTime) / TimeInterval(inOutputTime.pointee.videoTimeScale)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.motionPhysics == nil {
motionPhysics = .init(configuration: self.configuration, timeStep: Float(inOutputInterval - inNowInterval))
}
self.updatePosition()
}
return kCVReturnSuccess
}
CVDisplayLinkStart(displayLink)
}
func updatePosition() {
guard let destinationPoint, let motionPhysics else { return }
let windowCenter = CGPoint(x: frame.midX, y: frame.midY)
let currentState = currentMotionState ?? .init(position: windowCenter, velocity: .zero)
let nextState = motionPhysics.calculateNextState(from: currentState, destinationPoint: destinationPoint)
if abs(nextState.velocity.horizontal) < 0.01 && abs(nextState.velocity.vertical) < 0.01 {
self.stopMotion()
return
}
self.setFrame(.init(x: nextState.position.x - self.frame.width / 2,
y: nextState.position.y - self.frame.height / 2,
width: self.frame.width,
height: self.frame.height), display: false)
self.currentMotionState = nextState
}
func stopMotion() {
guard let link = displayLink else { return }
CVDisplayLinkStop(link)
displayLink = nil
motionPhysics = nil
currentMotionState = nil
}
}
Result
Performance
We tested the impact of physics calculations on the CPU load and battery drain depending on the number of objects that move simultaneously. The experiments ran with the same code we posted above, and we simply kept adding more supplementary windows and dragged the main window around nonstop. Below are the peak resource consumption stats we got in Xcode on a 2021 MacBook Pro with the M1 Max chip.
Number of windows | CPU load | Battery impact |
---|---|---|
1 | ||
10 | ||
25 | ||
50 | ||
100 |
The app started noticeably lagging at 25 windows, and the further we went, the more ridiculous the lag got. Looks like if we were to move views instead of windows, the possible lag-less threshold would be higher. Nevertheless, the point we're trying to make here is that one shouldn't go overboard with this logic. Use it sparingly so as not to strain the user's resources and apply it for a few UI components at a time.
Conclusion
We've implemented a custom movement logic for windows by applying real-world physics equations to update the window positions regularly on the main thread. The title of this article states that this approach can be applied to any UI component on iOS/macOS, which is true: we've applied the same algorithm to move views and layers with only slight differences in the positioning logic. Check out the CocoaSprings repository for a more in-depth look at the approach, and feel free to contribute if you have any improvements in mind.
Thanks for reading 👋🏻
References
- CocoaSprings
- Damped Springs by Ryan Jackett
- JNWAnimatableWindow by Jonathan Willing