Implementing Physics-based Movement for UIKit/AppKit Components

Article's main picture
  • #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:

The grey circle pulls to wherever we drag the small red circle
The grey circle pulls to wherever we drag the small red circle

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:

Our example app
Our example app

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:

  1. Make a transparent borderless window with a layer-hosting view and cover the user's screen with it.
  2. 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.
  3. Animate the screenshot layer's position/opacity/whatever with a CAAnimation.
  4. 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 when 0 < dampingRatio < 1, dampingRatio == 1, and dampingRatio > 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 current state of motion and returns the next state on the way to destinationPoint.
  • calcualteAllStates(from initialState:destinationPoint) — will return an array of all motion states starting with the initialState and all the way to when an object stops moving at its destinationPoint.
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 windowsCPU loadBattery 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

More From research

Subscribe to our newsletter