Ad

Our DNA is written in Swift
Jump

Cubed CoreAnimation Conundrum

One of the big mysteries of CoreAnimations are 3D transforms. You might have seen them used in popular apps like Flipboard (page turn) but there are hardly any good tutorials in how they actually work. In fact, you find only one from 2008 what was written when OS X Leopard was still around.

I blame that there are several things that are counterintuitive about using 3D transforms and perspective with CoreAnimation why not more people play with it … and then write up what they learned in some useful guide.

Session 421 Core Animation Essentials  from WWDC 2011 had an example of 6 squares that would animate into a three-dimensional box that the presenter could even rotate around. That inspired me to figure out how to do this as well, and with the help from several people on twitter I was successful.

Layers back all views on iOS. You can think of layers as only in charge of what you see while views have many more purposes including touch handling. If you set a transform on a UIView this is strictly two-dimensional. At least facing the developer, because behind this simplified interface at the UIKit level hides a powerful world. On the CoreAnimation level all transforms are CATransform3D i.e. three-dimensional.

Layers are also the thing that you actually see when you look at an iOS app. Basic CALayer instances have two ways how they can show content. Either you set their contents property to point to a CGImage or you point their delegate to some other object that knows how to perform drawLayer:inContext: for the layer. Of course layers can also have a backgroundColor set and if you subclass them you can override the drawLayer.

Let’s Be Square

We will start out by creating 6 layers for the sides of the cube. Contrasting got UIViews where the anchor point will always be at the top left, in CALayers the default anchorPoint is at (0.5, 0.5) which is in its center. The anchorPoint will also the point around which 3D rotations will occur, so we have to move this to the edges. So we set up all layers with bounds of (0,0,100,100), adjust the anchorPoint where it should be and then change the position such that the squares line up.

For simplicity I’m just coloring the individual squares differently. As I mentioned above – in real life – the contents of these layers could come from images or being drawn in code.

CALayers don’t preserve three-dimensionality of their sublayers. Say if you have a layer that has a sublayer rotated in 3D space then you will only ever see the two-dimensional projection of the sublayer onto the layer at z=0. This is why most people fail with even their very first 3D attempts because nobody tells them about this.

There is a specialized layer class CATransformLayer which does NOT  simplify 3D-transformed geometry of sublayers so this is what we shall use as the base layer for our experiment. I used a simple single view template project and added the setup for the squares to viewDidLoad.

@implementation DTViewController
{
	CATransformLayer *baseLayer;
	CALayer *greenLayer;
	CALayer *magentaLayer;
	CALayer *blueLayer;
	CALayer *yellowLayer;
	CALayer *purpleLayer;
 
	BOOL isThreeDee;
}
 
- (void)viewDidLoad
{
	[super viewDidLoad];
 
	UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
	[self.view addGestureRecognizer:pan];
 
	UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tap:)];
	[self.view addGestureRecognizer:tap];
 
	self.view.backgroundColor = [UIColor blackColor];
 
	baseLayer = [CATransformLayer layer];
	baseLayer.anchorPoint = CGPointZero;
	baseLayer.bounds = self.view.bounds;
	baseLayer.position = CGPointMake(CGRectGetMidX(self.view.bounds), CGRectGetMidY(self.view.bounds));
 
	[self.view.layer addSublayer:baseLayer];
 
	CALayer *redLayer = [CALayer layer];
	redLayer.backgroundColor = [UIColor redColor].CGColor;
	redLayer.frame = CGRectMake(0, 0, 100, 100);
	redLayer.position = CGPointMake(0,0);
	[baseLayer addSublayer:redLayer];
 
	blueLayer = [CALayer layer];
	blueLayer.backgroundColor = [UIColor blueColor].CGColor;
	blueLayer.bounds = CGRectMake(0, 0, 100, 100);
	blueLayer.anchorPoint = CGPointMake(1, 0.5); // right
	blueLayer.position = CGPointMake(-50,0);
	[baseLayer addSublayer:blueLayer];
 
	yellowLayer = [CALayer layer];
	yellowLayer.backgroundColor = [UIColor purpleColor].CGColor;
	yellowLayer.bounds = CGRectMake(0, 0, 100, 100);
	yellowLayer.anchorPoint = CGPointMake(0.5, 1); // bottom
	yellowLayer.position = CGPointMake(0,-50);
	[baseLayer addSublayer:yellowLayer];
 
	purpleLayer = [CALayer layer];
	purpleLayer.backgroundColor = [UIColor yellowColor].CGColor;
	purpleLayer.bounds = CGRectMake(0, 0, 100, 100);
	purpleLayer.anchorPoint = CGPointMake(0.5, 0); // top
	purpleLayer.position = CGPointMake(0,50);
	[baseLayer addSublayer:purpleLayer];
 
	// need a transform layer for green to mount magenta on
	greenLayer = [CATransformLayer layer];
	greenLayer.bounds = CGRectMake(0, 0, 100, 100);
	greenLayer.anchorPoint = CGPointMake(0, 0.5); // left
	greenLayer.position = CGPointMake(50,0);
	[baseLayer addSublayer:greenLayer];
 
	CALayer *greenSolidLayer = [CALayer layer];
	greenSolidLayer.backgroundColor = [UIColor greenColor].CGColor;
	greenSolidLayer.bounds = CGRectMake(0, 0, 100, 100);
	greenSolidLayer.anchorPoint = CGPointMake(0, 0); // top left
	greenSolidLayer.position = CGPointMake(0,0);
	[greenLayer addSublayer:greenSolidLayer];
 
	// the "lid"
	magentaLayer = [CALayer layer];
	magentaLayer.backgroundColor = [UIColor magentaColor].CGColor;
	magentaLayer.bounds = CGRectMake(0, 0, 100, 100);
	magentaLayer.anchorPoint = CGPointMake(0, 0.5); // left
	magentaLayer.position = CGPointMake(100,50);
	magentaLayer.doubleSided = YES;
	[greenLayer addSublayer:magentaLayer];
 
	CATransform3D initialTransform = baseLayer.sublayerTransform;
	initialTransform.m34 = 1.0 / -500;
	baseLayer.sublayerTransform = initialTransform;
}

After setting the view controller’s view’s backgroundColor to black we create the baseLayer which is in charge of preserving the three-dimensionality of its sublayers. CATransformLayers themselves cannot have a backgroundColor rather they serve as invisible structure elements.

The red, blue, yellow, purple and magenta layers are straightforward. All of them get mounted on the baseLayer, all 100*100 in size but with differing anchor points and positions. Again, the anchorPoint values need to be different because those will be used as hinges for the rotations later. Also note the position of the baseLayer which was placed such that (0,0) is in the center of the view.

The lid of the box hinges on the right side of the green square. If we were to mount the magenta lid directly on a green square then upon rotating that you would only see a flattened projection of the magenta layer into the plane of the green layer. Thus we need another invisible CATransformLayer as stand in for the green side to preserve the 3D-rotation of the lid and we mount the green square on this. Without it the lib would become invisible as it is rotated to be at a right angle from the green layer because the projection of it would be just a line.

The final paragraph of the above code sets the m34 field of the sublayerTransform of our baseLayer. This value determines the amount of perspective skewing applied to its sublayers when rotated in 3D space. Think of the -500 as if you are hovering your eye 500 pixels above the plane of the screen. The closer this value is to 0 the wider your angle of viewing will be.

Here’s is a value of m34 = 1/-120, really close.

And here we are further away with m34 = 1/-1200. This is so “far away” that a rotation does barely skew the yellow layer.

As a rule of thumb you can assume that the “closer” you get, the more perspective skewing will occur. We only apply all transforms to the sublayerTransform of the baseLayer because we only want the subLayers to be rotated.

I have no better explanation why the sublayerTransform and not the regular transform. We need to set the m34 only once at the beginning because subsequent incremental rotations will also affect this value. During my experiments I kept setting this to the initial value which caused the perspective to be wrong. Just set it once to fix the imaginative distance and then leave it alone.

Let’s Get Rolling

Now the whole thing is way more fun if we can rotate it around the x and y axis by dragging our finger on the screen. The basis for this technique was first written up by Bill Dudney in 2008, I’ve adapted it for use with a pan gesture recognizer.

This technique works by rotating the previous 3D transform around the x and y axis. Thereby one pixel travelled equates to one degree of rotation, hence the division by 180 and multiplication with Pi to convert it into radians.

CATransform3DRotate is a bit more complicated because it does the two rotations in one. It basically has just one angle value and then you need factors for x, y and z determining how much of this angle is applied to each axis. But here my mathematical knowledge of 3D matrices ends. Fortunately we don’t need to know any more since this formula works.

- (void)pan:(UIPanGestureRecognizer *)gesture
{
	if (gesture.state == UIGestureRecognizerStateChanged)
	{
		CGPoint displacement = [gesture translationInView:self.view];
		CATransform3D currentTransform = baseLayer.sublayerTransform;
 
		if (displacement.x==0 && displacement.y==0)
		{
			// no rotation, nothing to do
			return;
		}
 
		CGFloat totalRotation = sqrt(displacement.x * displacement.x + displacement.y * displacement.y) * M_PI / 180.0;
		CGFloat xRotationFactor = displacement.x/totalRotation;
		CGFloat yRotationFactor = displacement.y/totalRotation;
 
		if (isThreeDee)
		{
			currentTransform = CATransform3DTranslate(currentTransform, 0, 0, 50);
		}
 
		CATransform3D rotationalTransform = CATransform3DRotate(currentTransform, totalRotation,
																				  (xRotationFactor * currentTransform.m12 - yRotationFactor * currentTransform.m11),
																				  (xRotationFactor * currentTransform.m22 - yRotationFactor * currentTransform.m21),
																				  (xRotationFactor * currentTransform.m32 - yRotationFactor * currentTransform.m31));
 
		if (isThreeDee)
		{
			rotationalTransform = CATransform3DTranslate(rotationalTransform, 0, 0, -50);
		}
 
		[CATransaction setAnimationDuration:0];
 
		baseLayer.sublayerTransform = rotationalTransform;
 
		[gesture setTranslation:CGPointZero inView:self.view];
	}
}

The totalRotation is the length of the displacement vector, the xRotationFactor and yRotationFactor determine how much these elements contribute to the totalRotation. At the end of the gesture recognizer handler we setTranslation back to zero so that on the next update we get a delta only. There is a possibility of the deltas both being 0 so we have to prevent doing any calculation with that as it would mess up our beautiful transform with non-a-numbers (NAN).

The isThreeDee are a little bonus: When the box is flat then we want to rotate around z=0, but when it is closed then we want to rotate around its center at z=50. So we move the baseLayer before the incremental rotation and move it back afterwords if isThreeDee is YES.

Note that moving the finger along the x axis rotates around the y and vice versa.

Close The Box Already!

Alright, your wish is my command. We set up the squares with the appropriate hinges and with dragging the finger we can witness the awesome 3D-ness about to come from any perspective we wish.

Closing the box remains as a mere formality.

- (void)tap:(UITapGestureRecognizer *)gesture
{
	isThreeDee = !isThreeDee;
 
	if (isThreeDee)
	{
		greenLayer.transform = CATransform3DMakeRotation(-M_PI_2, 0, 1, 0);
		blueLayer.transform = CATransform3DMakeRotation(M_PI_2, 0, 1, 0);
		yellowLayer.transform = CATransform3DMakeRotation(-M_PI_2, 1, 0, 0);
		purpleLayer.transform = CATransform3DMakeRotation(M_PI_2, 1, 0, 0);
		magentaLayer.transform = CATransform3DMakeRotation(0.8*-M_PI_2, 0, 1, 0);
	}
	else
	{
		greenLayer.transform = CATransform3DIdentity;
		blueLayer.transform = CATransform3DIdentity;
		yellowLayer.transform = CATransform3DIdentity;
		purpleLayer.transform = CATransform3DIdentity;
		magentaLayer.transform = CATransform3DIdentity;
	}
}

When the box is flat then all transforms are the identity transforms. To close it there are rotations by the amount of half of Pi (= 90 degrees) with the 1 specifying the axis around which to rotate the plane. Only the magentaLayer I did not fully rotate so that we can still peek inside the box. Positive rotations are counter clockwise, negative clockwise.

I have to admit that experimentation yielded the correct values much faster than if I had thought it through. In any case, tapping the display now will close the box with a duration of one second which is the default CoreAnimation implicit animation duration.

Now you can also see that the magenta (aka pink) side stays connected with the green side which only was possible by using a CATransformLayer for this side.

You still don’t believe me? Ok, here is how it looks if you add the green layer directly to baseLayer and the pink layer directly to the green layer.

You see that only a projection of the pink layer onto the plane of the green one is visible. If this were rotated the full 90 degrees it would be no longer visible.

Conclusion

There are multiple items that were not immediately obvious to me. The most fundamental one was the need for CATransformLayer which was pointed out to me by Friedrich Markgraf and Andreas Monitzer on Twitter. The second stumbling block was when and how to set up m34.

Finally I don’t think I would have been able to get the touch-based rotation to work if it weren’t for Bill Dudney who amazingly joined and quit Apple in the long time since the blog post where he described the algorithm.

Originally I wanted to apply a 3D transform to a table view to visualize the individual elements comprising it. But I failed for the simple reason that even with a 3D translation applied to the sublayers I would still end up with a flat surface. Now you know why: no CATransformLayers anywhere in the view hierarchy and everything is projected flat onto the host layer.

At least I think that this is the explanation. Please let me know if you know of a method to have the subviews be hovering above the plane of the app’s window.

But at least know we know how this Cubic business works. 🙂

Source code of the project is available in my Examples GitHub repository.


Categories: Recipes

5 Comments »

  1. Great Work! was useful for me thanks!

  2. Many thank for this!

  3. very helpful… great work