I’ve been researching Core Animation
framework for the past few months.
I’ve started with several books on the subject, but I found watching related WWDC videos most rewarding. The presenters put the content in a relevant context which makes it easier to apprehend and learn from it.
One WWDC session particularly intrigued me: “2011–421 Core Animation Essentials”. They presented their demo named: “Layers in Perspective”, and it showed six layers, forming a flattened cube:
The sixth layer is hiding behind the layer number 5. It has a lower zPosition
then the layer above it.
They have also demonstrated opening and closing the cube formation.
So, I have decided to demonstrate that.
Here’s a link to a GitHub repo with the source code:
Core Animation 3D Cube
Here’s the animation:
You can also see it on You Tube
Layers in Core Animation
live in 3D
geometry. But a layer is a 2D
construct, so Core Animation
coordinate space is called a 2.5D
geometry.
To illustrate that just see what happens when you mess up your transformations.
Layers are 2D
objects, they don’t understand the third dimension.
They are like playing cards in space., and there is no depth buffer
available.
And also, intersecting layers should be avoided because in the image above, Core Animation needs to do a lot of work. So just to draw the red
layer intersecting only the blue
layer, Core Animation needs to
red
layer into two piecesred
layerblue
layerred
layer again And all that work is for just intersection, and here we have multiple.I wanted the project to fun on the macOS
, iOS
and iPadOS
, so I used AllApples Swift package.
After removing the storyboards and pimping up the AppDelegate
and main.swift
for the macOS
version, and SceneDelegate
for the mobile versions, I was ready to start.
main.swift
for the macOSimport Cocoa
let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
macOS
import Cocoa
import AllApples
class AppDelegate: NSObject, NSApplicationDelegate {
private var window: NSWindow?
func applicationDidFinishLaunching(_ aNotification: Notification) {
window = AppSceneDelegate.makeWindow_Mac(theVC: CommonViewController())
}
}
iOS
and iPadOS
import UIKit
import AllApples
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let aScene = (scene as? UIWindowScene) else { return }
window = AppSceneDelegate.makeWindow_iOS(theScene: aScene, theVC: CommonViewController())
}
}
The first I did is make a PlainLayerView
. It is an AView
descendant, which means that it is typedef-ed
to be either a UIView
or a NSView
.
It is an intermediary object just to set up a thing on the macOS
as well (doesn’t work yet).
I then created CustomLayerView
to have nice sides for the cube, with CATextLayer
as the number of the cube side, and a nice rounded border, so that we can peek into our cube while rotating.
Here’s the image of all six layers drawn by using a CustomLayerView
This layout was abandoned because I couldn’t make the transformation of purple
view to work when transforming in 3D
.
The solution is to add an additional CATransformLayer
to the green
layer, and mount the purple
layer onto it. As explained in this blog post by Oliver Drobnik Cubed CoreAnimation Conundrum.
But I didn’t want to lose the linearity of the solution, and I used the approach demonstrated in the mentioned WWDC session: “2011–421 Core Animation Essentials”
They used the zOrder
property of a layer, and so I put purple
layer on top of the red
layer to achieve that.
if number == 4 {
view.layer.zPosition = 1
}
As you can see in the image below, the purple
layer is in front of the red
layer, which is obvious when we rotate the flattened cube.
I did take the approach that Over Drobnik did in his article: Cubed CoreAnimation Conundrum , and used it like this:
side4.layer?.zPosition = on ? CACube3DView.sideWidth : 1
side1.layer?.transform = on ? CATransform3D.transformFor3DCubeSide(1, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side2.layer?.transform = on ? CATransform3D.transformFor3DCubeSide(2, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side3.layer?.transform = on ? CATransform3D.transformFor3DCubeSide(3, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side4.layer?.transform = on ? CATransform3D.transformFor3DCubeSide(4, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side5.layer?.transform = on ? CATransform3D.transformFor3DCubeSide(5, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side6.layer?.transform = on ? CATransform3D.transformFor3DCubeSide(6, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
I didn’t use his transform code, since he used that additional CATransformLayer
, so it wouldn’t work. So, here’s a small extension on the CATransform3D
public extension CATransform3D {
static func transformFor3DCubeSide(_ number: Int, zWidth: CGFloat) -> CATransform3D {
let halfPi = CGFloat(Double.pi) / 2.0
var trans = CATransform3DIdentity
switch number {
case 1:
trans = CATransform3DMakeRotation(halfPi, 0, 1, 0)
break
case 2:
trans = CATransform3DIdentity
break
case 3:
trans = CATransform3DMakeRotation(-halfPi, 0, 1, 0)
break
case 4:
trans = CATransform3DMakeTranslation(0, 0, zWidth)
break
case 5:
trans = CATransform3DMakeRotation(-halfPi, 1, 0, 0)
break
case 6:
trans = CATransform3DMakeRotation(halfPi, 1, 0, 0)
break
default:
break
}
return trans
}
}
I actually used the approach form that WWDC session, and also used anchorPoint
properties of the CALayer
.
Here’s a little extension on CGPoint
that returns a tuple of our cube side positions and anchor points:
public extension CGPoint {
static func anchorPointAndPositionForCubeSideLayer(number: Int, sideSize: CGFloat) -> (anchorPoint: CGPoint, position: CGPoint) {
var resultAnchorPoint = CGPoint(x:0.5, y:0.5)
var resultPosition = CGPoint(x:0.0, y:0.0)
let halfSideSize: CGFloat = sideSize / 2.0
switch number {
case 1:
resultAnchorPoint = CGPoint(x:1.0, y:0.5)
resultPosition = CGPoint(x:-halfSideSize, y:0.0)
break
case 2:
resultAnchorPoint = CGPoint(x:0.5, y:0.5)
resultPosition = CGPoint(x:0.0, y:0.0)
break
case 3:
resultAnchorPoint = CGPoint(x:0.0, y:0.5)
resultPosition = CGPoint(x:halfSideSize, y:0.0)
break
case 4:
resultAnchorPoint = CGPoint(x:0.5, y:0.5)
resultPosition = CGPoint(x:0.0, y:0.0)
break
case 5:
resultAnchorPoint = CGPoint(x:0.5, y:1.0)
resultPosition = CGPoint(x:0.0, y:-halfSideSize)
break
case 6:
resultAnchorPoint = CGPoint(x:0.5, y:0.0)
resultPosition = CGPoint(x:0.0, y:halfSideSize)
break
default:
break
}
return (anchorPoint: resultAnchorPoint, position: resultPosition)
}
}
In the image below I have marked where the anchor points
are for each layer:
The only fallacy in the image above, is that the purple
layer is actually above our red layer
, but I wanted to show where those anchor points are.
So, the actual image looks like this, but we now don’t see the red
layer.
An Anchor point is a center of rotation. It determines how will the layer rotate.
Imagine holding a playing card with two fingers. Then try to spin the card. The anchor point of that card is where you are holding it with fingers.
I made a small GestureRecognizerView
to be able to respond to gestures and move, rotate and scale our layers.
It hooks-up:
NSPanGestureRecognizer
and UIPanGestureRecognizer
func rotate(with event: NSEvent)
and UIRotationGestureRecognizer
NSClickGestureRecognizer
and UITapGestureRecognizer
func magnify(with event: NSEvent)
and UIPinchGestureRecognizer
It then exposes all those events to the developer to use:
public extension GestureRecognizerView {
@objc func rotationChanged(degrees: Float) {}
@objc func rotationChanged(radians: Float) {}
@objc func displacementChanged(displacement: CGPoint) {}
@objc func scaleChanged(scale: CGFloat) {}
@objc func tapHappened() {}
}
CACube3DView
will hold the six layers than (can) make a cube. In order for Core Animation
to render the transformed views in perspective, there is a property sublayerTransform
.
You either use that property of your parent layer, or add another layer class to your layer hierarchy: CATransformLayer
. I opted to use that.
private(set) public lazy var transformedLayer: CALayer = {
let l = CATransformLayer()
l.name = "Transform Layer"
#if os(OSX)
l.isGeometryFlipped = true
#endif
return l
}()
When adding sublayers, I add them to this transformedLayer
property, and not my view’s layer.
func addSideSubview(_ subview: AView) {
addSubview(subview)
#if os(iOS) || os(tvOS)
transformedLayer.addSublayer(subview.layer)
#endif
#if os(OSX)
if let aLayer = subview.layer {
transformedLayer.addSublayer(aLayer)
} else {
fatalError("`subview.layer` == `nil`")
}
#endif
}
When the app first runs it shows in perspective.
I made a little extension:
public extension CATransform3D {
static func somePerspectiveTransform() -> CATransform3D {
var perspective = CATransform3DIdentity;
perspective.m34 = -1.0 / 500.0;
perspective = CATransform3DRotate(perspective, CGFloat(Double.pi) / 8, 1, 0, 0);
perspective = CATransform3DRotate(perspective, CGFloat(Double.pi) / 8, 0, 1, 0);
perspective = CATransform3DScale(perspective, 0.7, 0.7, 0.7)
return perspective
}
}
The part: perspective.m34 = -1.0 / 500.0;
sets the perspective. The .34
field of a matrix shows the amount of perspective distortion applied. The amount 500
is often used in examples. If it were smaller, the layers would seem very close and distorted, like a fish-eye effect.
This is the initial transform, but we want to be able to move and rotate our cube (flattened or not) with our fingers.
Here’s the code:
public extension CATransform3D {
func rotationFromDisplacement(_ displacement: CGPoint, sideWidth: CGFloat, is3D: Bool) -> CATransform3D {
var currentTransform = self
let totalRotation: CGFloat = sqrt(displacement.x * displacement.x + displacement.y * displacement.y)
let angle: CGFloat = totalRotation * .pi / 180.0
let xRotationFactor = displacement.x / angle
let yRotationFactor = displacement.y / angle
if is3D {
currentTransform = CATransform3DTranslate(currentTransform, 0, 0, sideWidth / 2.0)
}
var rotationalTransform = CATransform3DRotate(currentTransform, angle,
(xRotationFactor * currentTransform.m12 - yRotationFactor * currentTransform.m11),
(xRotationFactor * currentTransform.m22 - yRotationFactor * currentTransform.m21),
(xRotationFactor * currentTransform.m32 - yRotationFactor * currentTransform.m31))
if (is3D) {
rotationalTransform = CATransform3DTranslate(rotationalTransform, 0, 0, -sideWidth / 2.0);
}
return rotationalTransform
}
}
We call it from our pan-gesture methods
override func displacementChanged(displacement: CGPoint) {
guard !(displacement.x == 0 && displacement.y == 0) else { return }
let rotationTransform = transformedLayer.sublayerTransform.rotationFromDisplacement(displacement, sideWidth: CACube3DView.sideWidth, is3D: isOn)
transformedLayer.sublayerTransform = rotationTransform
}
We hooked up the pan-gestures prior:
#if os(OSX)
private func setupPanGestureRecognizer() {
let panGR = NSPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
addGestureRecognizer(panGR)
}
@objc func handlePanGesture(_ gestureRecognizer: NSPanGestureRecognizer) {
let displacement: CGPoint = gestureRecognizer.translation(in: self)
handlePan(displacement: displacement, changed: gestureRecognizer.state == .changed)
}
#endif
#if os(iOS) || os(tvOS)
private func setupPanGestureRecognizer() {
let panGR = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
addGestureRecognizer(panGR)
}
@objc func handlePanGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
let displacement: CGPoint = gestureRecognizer.translation(in: self)
handlePan(displacement: displacement, changed: gestureRecognizer.state == .changed)
if gestureRecognizer.state == .changed {
gestureRecognizer.setTranslation(.zero, in: self)
}
}
#endif
We can also add a simple rotation transform for the rotation events/ gestures.
override func rotationChanged(radians: Float) {
let transform = transformedLayer.sublayerTransform
let rot = CATransform3DRotate(transform, CGFloat(radians), 0, 1, 0)
transformedLayer.sublayerTransform = rot
}
And scale, in all three axes:
override func scaleChanged(scale: CGFloat) {
let scaleTransform = CATransform3DScale(transformedLayer.sublayerTransform, scale, scale, scale)
transformedLayer.sublayerTransform = scaleTransform
}
The tap
turns on and off our 3D transform
override func tapHappened() {
set3DCube(on: isOn)
}
Here’s the 3D cube code
func set3DCube(on: Bool) {
side4.layer.zPosition = on ? CACube3DView.sideWidth : 1
side1.layer.transform = on ? CATransform3D.transformFor3DCubeSide(1, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side2.layer.transform = on ? CATransform3D.transformFor3DCubeSide(2, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side3.layer.transform = on ? CATransform3D.transformFor3DCubeSide(3, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side4.layer.transform = on ? CATransform3D.transformFor3DCubeSide(4, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side5.layer.transform = on ? CATransform3D.transformFor3DCubeSide(5, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
side6.layer.transform = on ? CATransform3D.transformFor3DCubeSide(6, zWidth: CACube3DView.sideWidth) : CATransform3DIdentity
}
We either set the identity transform
, which means no transform
, to our cube sides, or the transform appropriate for that particular side.
I suppose I need to do further investigation in how macOS
treats layers of NSView
, for this little experiment is not working on the macOS
, yet.
Here’s how the flattened cube looks on the macOS
So, the positioning of the layers is not respected.
And here is the cube in 3D:
I did try to force the isGeometryFlipped = true
everywhere. Anyway this needs more work.
If you want to help with the macOS
implementation, please, be my guest.
Here’s a link to a GitHub repo with the source code:
Core Animation 3D Cube