A few weeks ago I released Pause, a Mac app that I made to remind myself to take breaks periodically. If you’ve used the app, you know there just isn’t much UI in there. But I wanted to have one little party trick in there, which led to this:

This is the blog post I’d promised then, explaining how it works, and how you can build and use a similar animation in your app.

What Is the Genie Effect Anyway

Before we set about recreating it, we must first have a good understanding of how the animation works. Even if you’ve seen it multiple times a day for years, what it’s doing might not be entirely obvious.

Here’s a clip of the standard macOS genie animation in action:

Here’s a rough breakdown of what’s happening.

First up, the bottom edge of the window shears, shrinking in width and shifting towards its eventual final position, while the top edge remains fixed. The rest of the window contorts unevenly, forming a curve along either side.

Next, the window begins to translate down and scale. This is where the real magic of the animation comes in. The window doesn’t just translate and scale down though, it follows the path created by the two sides. It’s almost like a liquid flowing through a funnel, albeit a rather rigid liquid.

Building It

So how do we build this sort of animation.

If you’ve done any iOS or macOS UI work, this might seem impossible to do with a standard CALayer and the set of animation tools that ship with Core Animation. If we ignored the curved edges — and if I had the slightest clue about how those CATransform3D matrices work — that would be much more doable, but that’s not what we’re trying to do here. While perhaps not pixel perfect, we want something that at least comes close to matching the general vibe of the built in animation.

While we can represent the curved path using a UI/CGBezierPath, there isn’t really any way to contort a view to fit one, let alone then animate all of this.

One solution available to us while still sticking with the stock API here is that we don’t really need to use a single view: There’s nothing stopping us from taking a snapshot of our view and splitting it up a bunch of separate views, and then setting the frames and transforms for each of them. We can simulate the appearance of the entire shape being curved by splicing it up into small enough rectangles.

I can already hear the guffaws from some of you reading this, and to be honest I wouldn’t blame you for that reaction. This just isn’t how animations are usually handled. I’d like to assure you though that this isn’t entirely kooky, and while it was a backup strategy I didn’t end up using, the concept behind it is sound and basically what we want to achieve: being able to manipulate a view as if it’s a composite formed of multiple pieces rather than as a monolith.

As is turns out there is indeed a concept that represents this exact behaviour: Mesh transforms.

The idea behind them is straightforward. First you apply a set of vertices to your original shape. These vertices connect to certain neighbours to form shapes, collectively subdividing your view into a number of partitions.

Now you can move around any of the vertices, and all the partitions it touches will be distorted, and the partitions of the view they encompassed in the original shape will be distorted as well to fit the newly formed shapes.

In a way, for our use case it can be seen as a different take at the snapshot slicing technique from above; we just skip the steps of manually slicing up the snapshot and calculating individual transforms that make it so that the partitions all align perfectly for every frame, since the mesh system handles all of those details for us.

To be clear though, a mesh animation isn’t quite identical to the slicing technique; the slicing technique affords us much more control over the exact placement and contortion of the partition, although that power does require way more code on our part. If we wanted to recreate something like, say, the Passbook shredder animation from the days of yore iOS 6, a mesh transform simply won’t suffice, but slicing would do exactly what we need, allowing us to manipulate the slices as if they were actually cut apart, rather than separate but still connected. That said, a mesh is perfectly suited to our use case here, and so onwards.

The next question would of course be: Is there any first party API that would let us achieve these mesh transform? I’m glad you asked, and as it turns out there is! Core Animation includes a CAMeshTransform type that does pretty much what you’d expect. Just set one of those to your layer’s meshTransform and you’re done. I’ve also been told that there’s API in AppKit to set a window’s transform directly.

This seems like exactly what we’re looking for. There’s just one problem: It’s private API, all of it. Please do file a radar if you too would like to see that change.

While it isn’t available there as of this writing, I would like for Pause to be on on the Mac App Store someday, so while I considered using all of this API even so, I ended up deciding against this approach too.

After a bit of prodding around though I did find that there’s another way to use mesh transforms, although this one doesn’t involve Core Animation, UIKit, or AppKit…

Enter SpriteKit

SpriteKit, as it turns out, ships with some API to create mesh transforms, and public API at that.

While it can be used pretty seamlessly with UIKit and AppKit, SpriteKit is seen as more of a game framework than something used for building interfaces, and so some of you might not be familiar with it. Fret not though, we’ll only be touching a small fraction of the API surface for this one.

The biggest difference worth keeping in mind is that it uses a coordinate system with the origin at the bottom left, which might be a bit confusing for those coming from UIKit — or just right if you’re working with AppKit already. We only really care for a couple bits of API, so I’ll explain those in detail, and the others just enough to capture the point of what we want them for.

The Setup

Lets just get some of the stuff we need to set up in order to build the animation out of the way first. Since Pause is an AppKit app1, I’m building out the example app in an macOS playground. Regardless, the core of the animation is in SpriteKit and will thus work on both platforms with no issues.

The first bit of SpriteKit code we need is an SKView. This is the encapsulation used to bridge SpriteKit into UIKit or AppKit (and thus SwiftUI too, if you’re so inclined) code as it inherits UIView and NSView when used with those frameworks, respectively2.

Next, we need an SKScene. This is where the actual SpriteKit business goes down. All your SpriteKit nodes — these are the basic building blocks of SpriteKit code, analogous though not equivalent to CALayer — need to be rendered within a scene, which in turn needs to be presented within an SKView.

Lastly, we need an SKSpriteNode, which is a node capable of displaying an image. For now we’re going to have it show a screenshot of the same System Preferences screen as before. In practise, you’d use a snapshot of the view you want to transition. One thing to keep in mind is for views with shadows, the shadow will necessarily extend beyond the frame, and so you’ll have to update your frames accordingly.

All in all, this is what our basic setup looks like in code:

import SpriteKit
import PlaygroundSupport

let frame = CGRect(x: 0, y: 0, width: 800, height: 600)
let skView = SKView(frame: frame)
PlaygroundPage.current.liveView = skView

let scene = SKScene(size: frame.size)
scene.backgroundColor = .windowBackgroundColor

let imageNode = SKSpriteNode(imageNamed: "SysPrefs.png")
imageNode.position = CGPoint(x: frame.midX, y: frame.midY)
imageNode.size = frame.size
scene.addChild(imageNode)

skView.presentScene(scene)

And here’s how it renders:

It looks borked for now, but that’s intentional; we’ll get to that in a moment.

The Mesh

With all that out of the way, let us look at the mesh transform APIs for a second.

SpriteKit calls its mesh transformation API “warp geometry”, and it manifests in the form of 3 types:

  • SKWarpable is a protocol that types that can be warped conform to. We don’t use this directly, SKSpriteNode conforms to it which is what allows us to animate one.

  • SKWarpGeometry, which is the base class for warp geometries. We don’t use this one directly either.

  • SKWarpGeometryGrid, a subclass of SKWarpGeometry which lets you define a two dimensional mesh geometry grid, where every vertex connects to its neighbours along all 4 directions.

    There aren’t any other (public) subclasses of SKWarpGeometry, but this design does leave the door open to other, different kinds of grids; you might want to split your mesh into triangles, or hexagons, or some other kind of design with a mix of shapes, or maybe even let SpriteKit figure it out itself.

SKWarpGeometryGrid is the only one we’ll be using, and here’s how we’re going to do that.

class SKWarpGeometryGrid {
	convenience init(
		columns: Int,
		rows: Int,
		sourcePositions: [SIMD2<Float>] = [SIMD2<Float>](),
		destinationPositions: [SIMD2<Float>] = [SIMD2<Float>]()
	)
}

To instantiate a grid we need to pass in the number of columns and rows3, and alongside those we can optionally pass in the source and destination positions; if we skip either, it’ll assume that we mean the vertices to be positioned at regular intervals.

The array of positions is organised from as rows from bottom to top, and each row itself is organised from left to right. What this means is that the very first element reflects the position of the bottom left vertex, the second does it’s next neighbour to the right, and so on. If this seems a bit off, remember again that the origin is at the bottom left.

Each individual vertex position is represented as a SIMD2<Float> value. If you haven’t come across it before, SIMD2 is part of the set of SIMD vector types in the Swift standard library designed for faster parallel processing; for our use here though you can just think of it as a tuple of (Float, Float). The first value is the x coordinate of a vertex, and the second is the y.

The coordinates aren’t absolute values but relative to the node’s frame; if you had a node rendered at a size of 400×400 points, and wanted to place one of its vertices at 100 points off the left and 100 points off the bottom, you’d set the nodeʼs position to SIMD2(0.25, 0.25).

We can give this a run to make sure everything is working as expected. Our Playground is running at a size of 800×600, while the image has a square aspect ratio. Lets warp it to be centered and have a size of 400×400, using a warp geometry grid with 4 vertices.

But first, because we’re dealing with normalised4 positions with respect to the node’s bounds, I wrote up a small helper function to convert those:

public extension CGRect {
	func normalized(in other: CGRect) -> CGRect {
		CGRect(
			x: (origin.x - other.origin.x) / other.width,
			y: (origin.y - other.origin.y) / other.height,
			width: width / other.width,
			height: height / other.height
		)
	}
}

Now to set the initial frame using it:

let initialFrame = CGRect(x: 200, y: 100, width: 400, height: 400)
	.normalized(in: skView.bounds)
let initialPositions = [
	SIMD2(Float(initialFrame.minX), Float(initialFrame.minY)),
	SIMD2(Float(initialFrame.maxX), Float(initialFrame.minY)),
	SIMD2(Float(initialFrame.minX), Float(initialFrame.maxY)),
	SIMD2(Float(initialFrame.maxX), Float(initialFrame.maxY))
]
imageNode.warpGeometry = SKWarpGeometryGrid(
	columns: 1, 
	rows: 1, 
	destinationPositions: initialPositions
)
The initial state for our animation

The items in the initialPositions array are the positions for the bottom left, bottom right, top left, and then top right vertices, in that order. The explicit Float casting makes it even harder to read but that’s the best we can do until SE-307: Allow interchangeable use of CGFloat and Double types is merged.

We can also see what it’ll look like when minimised:

let finalFrame = CGRect(x: 640, y: 0, width: 50, height: 50)
	.normalized(in: skView.frame)
let finalPositions = [
	SIMD2(Float(finalFrame.minX), Float(finalFrame.minY)),
	SIMD2(Float(finalFrame.maxX), Float(finalFrame.minY)),
	SIMD2(Float(finalFrame.minX), Float(finalFrame.maxY)),
	SIMD2(Float(finalFrame.maxX), Float(finalFrame.maxY))
]
imageNode.warpGeometry = SKWarpGeometryGrid(
	columns: 1,
	rows: 1,
	destinationPositions: finalPositions
)
And the final state

Now that we have a bit more of a hang of how these warps work, on to the actual animation.

The Animation

Now for the fun stuff. First, a very brief look at the animation API.

Animations in SpriteKit are carried out using the SKAction class. It’s a different system from what you may be used to with Core Animation, so rather than diving into the details we’ll just look at the bits we care about, which is this one function:

class SKAction {
	class func animate(
		withWarps warps: [SKWarpGeometry],
		times: [NSNumber]
	) -> SKAction?
}

It lets us create an action from an array of warps and accompanying timestamps, which when run by a node, animates the geometries we’ve specified. So, yeah, we’re doing a keyframe animation.

Here’s what the basic setup for our animation looks like:

let slideAnimationEndFraction = 0.5
let translateAnimationStartFraction = 0.4

let duration = 0.7
let fps = 60.0
let frameCount = duration * fps

let rowCount = 1

let positions: [[SIMD2<Float>]] = stride(from: 0, to: frameCount, by: 1).map { frame in
	let fraction = (frame / (frameCount - 1))
	let slideProgress = max(0, min(1, fraction/slideAnimationEndFraction))
	let translateProgress = max(0, min(1, (fraction - translateAnimationStartFraction)/(1 - translateAnimationStartFraction)))
	
	/// calculate actual positions here
}

let warps = positions.map {
	SKWarpGeometryGrid(columns: 1, rows: rowCount, destinationPositions: $0)
}

let warpAction = SKAction.animate(
	withWarps: warps,
	times: warps.enumerated().map { 
		NSNumber(value: Double($0.offset) / fps)
	}
)!
imageNode.run(warpAction)

To start things off, pretty much all the stuff at the top is an estimate on my part, so feel free to play around with those. For the timing for the two sub-animations, I tested a bunch of different values and those are the ones that seemed closest to me. The duration is eyeballed too. For the display’s FPS, I know macOS is now better about supporting higher frame rates but I haven’t found a simple way to find the value for the current display, so we’re going with the trusted 60fps. If you know of a technique to find it, please do let me know.

As for the row count, in practise it’ll depend on the full size of the view you want to animate and the kind of performance characteristics you’re seeing on the various devices you’re targeting, but for this particular demo, I’ve found 50 is a good enough value. As a rough estimate, I’ve found that a row for every 5–10 pixels is good enough.

Next, we runs through every single frame of our animation, 42 in our case here, and calculates the current progression5 of both sub-animations. We’ll get to calculating the actual positions in a second.

Next, we map our positions to create an array of warps, and then use that to create an SKAction that handles our animation, and have our node run it.

As for calculating the positions, there are a lot of moving parts here, so let us break it down. As we noted earlier, there are two main parts to the animation, firstly the bottom edge shearing, and then the translation of the whole window along the path created by the sides. These two parts aren’t sequential though, the translation begins just a bit before the shrinking finishes, which is why the effect doesn’t appear as a combination of two disjointed animations. We’ll have to account for this as well.

The most important part of this whole setup though, is the curvature of the “funnel” we talked about earlier. Sure, the two subanimations drive the horizontal and vertical deformation of the view in rough terms, but they aren’t enough to derive the position of all vertices for a given frame.

We’re going to have to model for the curved paths in any case, so rather than doing so implicitly we’re going to design the whole animation around it, and as it turns out doing so will make this whole business much simpler too.

First, we can restate what the shearing sub-animation does in terms of what it does to the funnel rather than to the view itself. Instead of compressing and shifting the bottom edge of the view, we can see it as moving the bottom points of both curved paths from being collinear with the initial position’s edges to being at the top of the final position’s edges. The translation sub-animation now has the job of moving the view along between the paths.

As for the actual curvature, we’re going to model that using an easing function. While you’re probably most familiar with as timing functions for animations, an easing function is pretty much just a way to map a number, traditionally between 0 and 1, to another number, and can be used anywhere such a situation crops up6. We’re going to assume that the top of our curve is 0, and the bottom is 1. For any values in between, we’ll obtain their representation on this scale, apply our easing function, and then convert it back to the bezier’s full scale.

So, writing up all of that, here’s what our final code looks like.

let slideAnimationEndFraction = 0.5
let translateAnimationStartFraction = 0.4
let leftBezierTopX = Double(initialFrame.minX)
let rightBezierTopX = Double(initialFrame.maxX)

let duration = 0.7
let fps = 60.0
let frameCount = duration * fps

let rowCount = 50

/// 1
let leftEdgeDistanceToMove = Double(finalFrame.minX - initialFrame.minX)
let rightEdgeDistanceToMove = Double(finalFrame.maxX - initialFrame.maxX)
let verticalDistanceToMove = Double(finalFrame.maxY - initialFrame.maxY)

let bezierTopY = Double(initialFrame.maxY)
let bezierBottomY = Double(finalFrame.maxY)
let bezierHeight = bezierTopY - bezierBottomY

let positions: [[SIMD2<Float>]] = stride(from: 0, to: frameCount, by: 1).map { frame in
	let fraction = (frame / (frameCount - 1))
	let slideProgress = max(0, min(1, fraction/slideAnimationEndFraction))
	let translateProgress = max(0, min(1, (fraction - translateAnimationStartFraction)/(1 - translateAnimationStartFraction)))
	
	/// 2
	let translation = translateProgress * verticalDistanceToMove
	let topEdgeVerticalPosition = Double(initialFrame.maxY) + translation
	let bottomEdgeVerticalPosition = max(
		Double(initialFrame.minY) + translation,
		Double(finalFrame.minY)
	)
	
	/// 3
	let leftBezierBottomX = leftBezierTopX + (slideProgress * leftEdgeDistanceToMove)
	let rightBezierBottomX = Double(initialFrame.maxX) + (slideProgress * rightEdgeDistanceToMove)
	
	/// 4
	func leftBezierPosition(forY y: Double) -> Double {
		switch y {
		case ..<bezierBottomY:
			return leftBezierBottomX
		case bezierBottomY ..< bezierTopY:
			let progress = ((y - bezierBottomY) / bezierHeight).quadraticEaseInOut
			return (progress * (leftBezierTopX - leftBezierBottomX)) + leftBezierBottomX
		default:
			return leftBezierTopX
		}
	}
	
	func rightBezierPosition(forY y: Double) -> Double {
		switch y {
		case ..<bezierBottomY:
			return rightBezierBottomX
		case bezierBottomY ..< bezierTopY:
			let progress = ((y - bezierBottomY) / bezierHeight).quadraticEaseInOut
			return (progress * (rightBezierTopX - rightBezierBottomX)) + rightBezierBottomX
		default:
			return rightBezierTopX
		}
	}
	
	/// 5
	return (0 ... rowCount)
		.map { Double($0) / Double(rowCount) }
		.flatMap { position -> [SIMD2<Double>] in
			let y = (topEdgeVerticalPosition * position) + (bottomEdgeVerticalPosition * (1 - position))
			let xMin = leftBezierPosition(forY: y)
			let xMax = rightBezierPosition(forY: y)
			return [SIMD2(xMin, y), SIMD2(xMax, y)]
		}
		.map(SIMD2<Float>.init)
}

let warps = positions.map {
	SKWarpGeometryGrid(columns: 1, rows: rowCount, destinationPositions: $0)
}

let warpAction = SKAction.animate(
	withWarps: warps,
	times: warps.enumerated().map {
		NSNumber(value: Double($0.offset) / fps)
	}
)!
imageNode.run(warpAction)

One note before getting to anything else: We’re using Doubles everywhere and then mapping back to Float at the end because I noticed a bug once or twice in some testing when using Float. I haven’t been able to repro it since so it might be a fluke, but the Double version works just fine so I’m fine with it as well.

Lets go through the other changes in parts. In the first code block, we’re defining a bunch of constants related to the full animation such as the total translation for the top, left, and right edges, and the fixed top coordinates and height of the bezier curve.

In the second block, we’re calculating the translation of the top and bottom edges, based only off of the translation subanimation’s progress.

In the third block, we’re calculating the x axis values of the bottom coordinate of the two beziers, based only off of the shearing subanimation progress.

In the fourth block, we’re setting up functions to calculate the x value for a point at a particular height given the bezier mathematics we have, as we’d discussed earlier. Within the bezier, i.e. between the top edge of the initial position and top edge of the final position, we’re easing the relative position to find the position along the curve, and beyond the edges we’re extending the beziers vertically, to match the final position being a rectangle with straight edges.

It all comes together in the fifth block. We calculate positions in a row-wise fashion, starting from the bottom edge and moving upwards. For every row, we first calculate the y position by interpolating linearly between the top and bottom edges. Then we use the bezier functions defined in the fourth block to find the matching x positions.

And with that, let’s see this looks like in action:

Conclusion

With the exception that there are some changes because the animation moves upwards, and is wrapped in a single view, that is pretty much the exact code that ships in Pause. If you’d want to the animation with UIKit, you’d do something similar, along with some coordinate system transformations7 to ensure that the frames lines up.

You can look at the accompanying Playground for this blog post on GitHub. The Playground also covers some additional variations on the animation, such as being able to maximise, and also animate in or out of any edge of the minimised window. So you can do this:

Additionally, I’d recommend taking a look at Bartosz Ciechanowski’s BCGenieEffect. He recreated the effect over 8 years ago, using the image slicing method and CATransform3D as we’d discussed earlier.


  1. And because macOS Playgrounds are significantly more stable than the iOS kind.

  2. How exactly does that work? Is it just a bunch of #if canImport(AppKit) macros strewn all over? Your guess is as good as mine.

  3. Note that this is the number of columns and rows for the grid itself, not the number of columns and rows of vertices; i.e. if you have a setup of 3x3 vertices, you’d have a grid of 2 columns and 2 rows.

  4. Yes, I write prose in Indian English and code in US English. Yes, I hate it too. And no, I won’t switch either of them.

  5. The frameCount - 1 is there is there to ensure that the fraction is 0 for the zeroth frame and 1 for the last i.e. 41st frame.

  6. Galaxy brain: We’re always using easing functions when dealing with numbers, it’s just a linear easing function most times.

  7. Something like:

    extension CGRect {
    	func invertingCoordinateSystem(in superviewBounds: CGRect) -> CGRect {
    		CGRect(
    			x: origin.x,
    			y: superviewBounds.height - (origin.y + height),
    			width: width,
    			height: height
    		)
    	}
    }