iOS
Taking Control of Rotation Animations in iOS
Matt Neuburg
Written on May 21, 2021

We all love to have animation in our app’s interface. But sometimes writing even the simplest animation can be rather elusive.
Rotation is a case in point. As you probably know, the way to rotate a view (or a layer) is to change its transform, applying a rotation transform to it. But certain rotations are a little trickier to achieve than one might expect.
Turn Me Round
For example, suppose our goal is to perform, with animation, a complete rotation of a view — that is, we want to make the view execute a 360-degree turn around its center. You probably know about UIView animation, so your first instinct might be to apply a 360-degree rotation transform to the view, in an animation block, like this (the view is v
in my code):
UIView.animate(withDuration: 2) {
let angle = CGFloat.pi * 2
v.transform = CGAffineTransform(rotationAngle: angle)
}
Sounds reasonable, right? We go from the current transform to a new transform involving a 360-degree (2π radians) rotation, animating as we do so.
The trouble is, however, that you run that code and nothing happens.
Why? The code is formally correct; we know that because the animation works just fine for certain other angles. For example, if the angle is CGFloat.pi/2
, the view does just what we expect: it executes a quarter turn (90 degrees). But for CGFloat.pi*2
, evidently the runtime is saying to itself: “That rotation would put this view in the zero rotation position. But this view is already at the zero rotation position. So there’s nothing to do!”
Let’s test that hypothesis by trying out some other angles. Here are the results:
-
CGFloat.pi / 2
: Animates a clockwise rotation of 90 degrees. -
-CGFloat.pi / 2
: Animates a counterclockwise rotation of 90 degrees. -
CGFloat.pi * 99 / 100
: Animates a clockwise rotation of almost 180 degrees. -
-CGFloat.pi * 99 / 100
: Animates a counterclockwise rotation of almost 180 degrees. -
CGFloat.pi
: Animates a clockwise rotation of 180 degrees. -
-CGFloat.pi
: Animates a clockwise rotation of 180 degrees!
And it goes on from there. Evidently, as soon as we hit 180 degrees, the runtime stops obeying the sign and quantity of the angle as part of the rotation. It rotates the view to the correct angular position, but the rotation itself just takes the “shortest path” to reach that position.
But what if that isn’t what we want? How do you convey to the runtime that you really do mean what you’re saying? The problem is the same for any angle greater than 180 degrees: how do you make the runtime perform a rotation through the given angle in the given direction?
Hacks Galore
If you go on Stack Overflow looking for solutions to this problem, you’ll find that (1) yes, it’s a problem, and (2) the ingenuity of silly hacks knows no bounds.
For instance, you might encounter a suggestion that one could try to express a 360-degree animation as a chain of smaller animations. This requires that each animation’s completion handler should initiate the next animation. For instance, let’s use four 90-degree animations in a row:
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseIn) {
let angle = CGFloat.pi / 2
v.transform = CGAffineTransform(rotationAngle: angle)
} completion: { _ in
UIView.animate(withDuration: 0.5, delay: 0, options: .curveLinear) {
let angle = CGFloat.pi
v.transform = CGAffineTransform(rotationAngle: angle)
} completion: { _ in
UIView.animate(withDuration: 0.5, delay: 0, options: .curveLinear) {
let angle = CGFloat.pi * 3 / 2
v.transform = CGAffineTransform(rotationAngle: angle)
} completion: { _ in
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut) {
let angle = CGFloat.pi * 2
v.transform = CGAffineTransform(rotationAngle: angle)
}
}
}
}
That works, in the sense that we do see the view rotate one complete turn; but it’s clunky visually, and it’s even more clunky as code. Surely that can’t be the right way?
Another approach is to use a keyframe animation. That is certainly not a completely hacky idea. The whole point of a keyframe animation, after all, is that it lets us express particular stages through which an animation is to pass at particular times. So we can use a keyframe animation to write a 360-degree animation quite neatly as a sequence of four 90-degree frames, and when we do, we are using the keyframe animation just as it is intended to be used:
UIView.animateKeyframes(withDuration: 2, delay: 0) {
for i in 0..<4 {
UIView.addKeyframe(withRelativeStartTime: 0.25 * Double(i),
relativeDuration: 0.25) {
let angle = CGFloat.pi / 2 * CGFloat(i + 1)
v.transform = CGAffineTransform(rotationAngle: angle)
}
}
}
That works! Since each 90-degree keyframe rotation does what we want it to do, the entire animation does what we want it to do. The view turns smoothly round 360 degrees, and if we change the sign of the angle, it makes the same turn in the opposite direction.
Nevertheless, although this approach neatly solves this one particular problem, it’s hard to imagine that we would want to resort to it as a general technique. Are we really going to have to write code like that every time we want any rotation animation larger than 180 degrees? It feels here as if we are being forced to work around a functionality hole, making up for the lack of a capability that should have been built in to UIView animation but wasn’t.
But if this capability exists at all, and if it doesn’t live in the world of UIView animation, then where does it live?
Core Meltdown
The developer who is seriously in the know will likely be aware that UIView animation is just a sort of façade. Under the hood, all animation is really layer animation (CALayer). The way to animate a layer with as much control as possible is to use CAAnimation. For animating a property such as a layer’s transform
, that probably means CABasicAnimation. Maybe that’s the answer.
To find out, let’s rewrite our animation using CABasicAnimation. We are animating the layer’s transform, as we already know; it is a CATransform3D, not a CGAffineTransform. But apart from that, this should be fairly straightforward; the layer (lay
in my code) can be a view’s underlying layer, or indeed any layer at all:
let angle = CGFloat.pi*2
let spin = CABasicAnimation(keyPath: #keyPath(CALayer.transform)) // 1
spin.duration = 2
spin.fromValue = lay.transform
spin.toValue = CATransform3DMakeRotation(angle, 0, 0, 1) // 2
lay.add(spin, forKey: "spinAnimation") // 3
CATransaction.setDisableActions(true) // 4
lay.transform = spin.toValue as! CATransform3D
The code for this kind of layer animation is a lot more verbose and imperative than UIView animation, but its structure is largely boilerplate, so it’s not difficult to write. If you’ve never written CABasicAnimation code before, here are the main points to be aware of:
-
We must say, as a key path, what property of the layer our animation is supposed to animate. Here, it’s the layer’s
transform
. -
The
fromValue
andtoValue
of the animation are typed as Any; it is up to us to make sure the type is correct. (If we don’t, there won’t be a compile error, but the animation probably won’t work.) A layer’stransform
is a CATransform3D; so ourfromValue
andtoValue
must be CATransform3D values. Expressing a rotation in the plane of the screen as a CATransform3D is a bit more elaborate than expressing it as a CGAffineTransform, but not much; you just have to know what the extra parameters mean (they are thex
,y
, andz
components of a vector around which the rotation will take place, but I’m not going to explain any further, so just trust me on this one). -
We add the animation to the layer. The
key
for this call is generally arbitrary; in this example, it could equally well benil
. -
We also should set the actual transform of the layer to the same value that it will have at the end of the animation; otherwise, when the animation ends, the layer will jump back to its original appearance! But we don’t want the act of setting the transform of the layer to interfere with our animation of the transform, so we turn off implicit animation first.
It seems, however, that all of that work doesn’t actually get us any closer to our goal. It’s the same story as before. Our code works just fine if the angle is CGFloat.pi/2
, or any angle whose absolute value is smaller than CGFloat.pi
. But for angles of CGFloat.pi
and larger, the animation takes the shortest path, and for an angle of CGFloat.pi*2
, nothing happens.
Nothing Up My Sleeve
So what’s the trick for making this animation animate? Well, there’s actually more than one way to do it.
One technique is to apply a value function to the animation:
spin.valueFunction = CAValueFunction(name: .rotateZ)
This tells Core Animation that the values you are supplying as the fromValue
and toValue
are amounts of rotation — that is to say, angles — and that it should translate these into performing a rotation animation in the plane of the screen. So now we must change our fromValue
and toValue
; instead of transforms, these need to be angles (measured in radians). The overall animation keypath is still the layer’s transform
:
let angle = CGFloat.pi*2
let spin = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
spin.duration = 2
spin.valueFunction = CAValueFunction(name: .rotateZ) // 1
spin.fromValue = 0 // 2
spin.toValue = angle
lay.add(spin, forKey: "spinAnimation")
CATransaction.setDisableActions(true)
lay.transform = CATransform3DMakeRotation(angle, 0, 0, 1)
Here are the changes from the previous example:
-
I’ve configured the animation’s value function.
-
The
fromValue
andtoValue
are now angles.
This works perfectly! When we run this code, the layer rotates one complete turn of 360 degrees. Moreover, the animation now obeys the sign of the angle. If we change the angle
value to -CGFloat.pi.*2
, we get a complete turn performed in the opposite direction.
Nothing Up My Other Sleeve
An alternative technique is to use a rotation keypath instead of a value function. The keypath for a rotation animation within the plane of the screen is "transform.rotation"
. (Alternatively you could say "transform.rotation.z"
, but there is no need, as the z
axis is the default if you omit the axis.) Our code now looks like this:
let angle = CGFloat.pi*2
let spin = CABasicAnimation(keyPath: "transform.rotation") // meaning ".z"
spin.duration = 2
spin.fromValue = 0
spin.toValue = angle
lay.add(spin, forKey: "spinAnimation")
CATransaction.setDisableActions(true)
lay.transform = CATransform3DMakeRotation(angle, 0, 0, 1)
That works, but it is probably better to prefer the value function approach. The "transform.rotation"
approach has two downsides:
-
We can’t use Swift
#keyPath
notation to express the keypath, because a CATransform3D has norotation
property; this whole keypath string is a kind of trick notation that Objective-C Cocoa permits us, by special dispensation, to use through key–value coding, and Swift knows nothing about that. -
According to Apple, this way of expressing the rotation is not as accurate as using the value function, especially when multiple rotations are involved.
Unlock and Load
As a field test demonstrating our new-found confidence in performing rotation animations, here’s an actual situation that arose the other day, when BiTE CTO Brant found himself trying to write the code for an animation that should have been simple, and it just wasn’t working.
One of the fun benefits of being on a team is that sometimes, when you’re having a problem, you can just turn to someone else for a fresh pair of eyes and some new input and advice. So Brant explained to me what he was trying to do. As he told me, his troubles all revolved (pun intended) around this very issue: how to animate a rotation through any angle.
To make the larger problem clear, I’ll start by showing you my own mockup of the end result that Brant was trying to achieve.
The metaphor here is that there is a dial, like the dial on a simple combination lock such as one might have on a school locker. The idea is that we start with a sequence of numbers, and the animation should “turn the dial” to each of those numbers successively — with the added rule, typical of combination locks, that we must alternate directions as we go. I’ve made a little video sketch to demonstrate the sort of thing I mean.
As you can see, in that example, the lock’s “combination” is 7—9—6—1—5—2. The animation “dials” the lock by turning left (counterclockwise) to 7, then from there right (clockwise) to 9, then from there left (counterclockwise) to 6, and so on.
This ought to have been a fairly simple animation, but the problem Brant was having was that the rotation animation sometimes went the wrong way. The dial ended up in the desired position, but it didn’t turn in the correct direction to get there. At bottom, the cause was the very same problem with which I started this article! Brant just needed a reliable way of rotating by any angle in either direction.
Rock Around the Clock
Here’s how I developed the implementation shown in the video.
The animations we’ve been constructing with CABasicAnimation all have two values: the fromValue
, signifying the rotation angle at the beginning of the animation, and the toValue
, signifying the rotation angle at the end of the animation. In the example code, we’ve been assuming that the initial fromValue
is 0
; and at the very beginning of the first rotation animation, that’s just what it is. But once we’ve rotated from zero to the first number in the combination, each toValue
becomes the new fromValue
for the next rotation. So we’re going to need to generate a series of fromValue
—toValue
pairs, where each pair’s toValue
becomes the next pair’s fromValue
.
But what are those values? We’re not starting out with angles at all; we’re starting with a sequence of numbers (the combination), signifying “stops” on the “dial”. So clearly we need a way to turn each successive number of the combination into an angle. How are we going to do that?
Given the metaphor of rotating to successive “stops”, I think it will be easiest to think additively, in terms of numeric “distance” between “stops”. Given a pair of successive numbers in the combination, how many “stops” of the dial do I have to pass through, and in what direction, to go from the first to the second? It will then be trivial to turn each of those “stop” counts into a relative angle, and then we can animate to an absolute angle.
For example, let’s go back to our original “7—9—6—1—5—2” combination. Assuming there are ten stops on the dial and that we start at 0 (as in the video), I want the dial first to move counterclockwise 7 stops (from 0 to 7), then clockwise 8 stops (back past zero and then one more to 9), then counterclockwise 7 stops (from 9 past zero to 6), and so forth. Arbitrarily using positive to mean counterclockwise and negative to mean clockwise, this means I want to transform [7, 9, 6, 1, 5, 2]
to [7, -8, 7, -5, 4, -3]
.
Well, I have absolutely no clue what the algorithm should be for performing that transformation, and thinking about it makes my head spin worse than the dial on our combination lock. However, Brant had suggested to me, in the course of discussing the original problem, that there was probably no need to think about the algorithm; his way of working is to write some test cases and just tweak the code until the tests pass (test-driven development). Great idea!
So I created an enum expressing the notion of clockwise and counterclockwise, along with the stub for a countSteps
method in my ViewController:
enum Direction {
case counterClockwise
case clockwise
mutating func toggle() {
self = (self == .clockwise) ? .counterClockwise : .clockwise
}
}
func countSteps(from oldStop: Int, to newStop: Int, direction dir: Direction) -> Int {
var diff = newStop - oldStop
// now what?
return diff
}
(The Direction toggle
method will come in handy later when we actually implement the successive rotations of the dial.)
And here are some test case assertions saying what the right answer should look like:
let v = ViewController()
XCTAssertEqual(
v.countSteps(from: 1, to: 2, direction: .counterClockwise),
1)
XCTAssertEqual(
v.countSteps(from: 1, to: 2, direction: .clockwise),
-9)
XCTAssertEqual(
v.countSteps(from: 2, to: 1, direction: .counterClockwise),
9)
XCTAssertEqual(
v.countSteps(from: 2, to: 1, direction: .clockwise),
-1)
XCTAssertEqual(
v.countSteps(from: 2, to: 5, direction: .counterClockwise),
3)
XCTAssertEqual(
v.countSteps(from: 2, to: 5, direction: .clockwise),
-7)
XCTAssertEqual(
v.countSteps(from: 5, to: 2, direction: .counterClockwise),
7)
XCTAssertEqual(
v.countSteps(from: 5, to: 2, direction: .clockwise),
-3)
That should be enough to get along with. I’ll spare you a description of what happened over the next ten minutes; suffice it to say that, at the end of that time, I had this:
func countSteps(from oldStop: Int, to newStop: Int, direction dir: Direction) -> Int {
var diff = newStop - oldStop
if newStop > oldStop {
if dir == .clockwise {
diff -= 10
}
} else {
if dir == .counterClockwise {
diff += 10
}
}
return diff
}
Assume now that stops
is an array of positive integers between 1 and 9 inclusive, signifying the successive stop positions on the dial that constitute the combination. (For simplicity, I’ve omitted 0 as a possible stop in the combination.) I’ll transform that combination into a sequence of relative step counts between stops:
var moves = [Int]()
var prevStop = 0
var dir = Direction.counterClockwise
for stop in stops {
moves.append(self.countSteps(from: prevStop, to: stop, direction: dir))
prevStop = stop
dir.toggle()
}
Next, I’ll turn those relative step counts into relative angles, in radians:
let angles : [CGFloat] = moves.map { -CGFloat.pi * 2 / 10 * CGFloat($0) }
At last I’m ready to construct a series of animations!
I think the easiest way to express this series will be as a CAAnimationGroup. A CAAnimationGroup is basically just an animation with an animations
array property. The thing to remember when you’re configuring those animations is that you have to set the beginTime
of each animation so that it won’t actually start until the right moment has arrived. In my case, the right moment is after duration
of the previous dial animation has elapsed; I will also interpose a very brief additional delay between turns of the dial, for all animations except the first one.
My angles
values are additive, so I obtain each animation’s toValue
by adding the next angle to the current angle (the fromValue
). I set each animation’s duration
based on the size of the additive angle, so that it takes the same amount of time to turn from 0 to 9 as it does to turn from 0 to 1. Finally, I need to set each animation’s fillMode
to keep its presentation in place after it finishes, until the next animation takes over:
var animations = [CAAnimation]()
var currentAngle = 0 as CGFloat
var accumulatedTime = 0 as Double
var first = true
for additiveAngle in angles {
let delay = first ? 0.0 : 0.3
let absoluteAngle = currentAngle + additiveAngle
let b = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
b.valueFunction = CAValueFunction(name: .rotateZ)
b.fromValue = currentAngle
b.toValue = absoluteAngle
b.beginTime = delay + accumulatedTime
let magicNumber = 0.7 // 0.7 is a "magic number" (hey, Einstein did it)
b.duration = abs(Double(additiveAngle)) * magicNumber
b.fillMode = .forwards // otherwise we snap back to zero between numbers
animations.append(b)
currentAngle = absoluteAngle
accumulatedTime += delay + b.duration
first = false
}
My animations are now sitting in the animations
array. I combine them into an animation group and attach that animation group to the layer:
let group = CAAnimationGroup()
group.animations = animations
group.duration = accumulatedTime
group.setValue("group", forKey: "name")
self.lay.add(group, forKey:"group") // arbitrary, could be nil
CATransaction.setDisableActions(true)
self.lay.transform = CATransform3DMakeRotation(currentAngle, 0, 0, 1)
The result is the video I showed earlier. I’ve posted an Xcode project that demonstrates the code, so feel free to download and play with it!