Hamburger Button Animation
Last week I wrote about Google's Authentic Motion. The post was kind of dry, though, so I decided to use the derived timing function to implement one of Android's new animations. A hamburger button transition seems to be fitting iOS design language1 and that's what I've chosen to recreate. It may become a good alternative to another recently introduced transition.
Initial Setup
After watching the above video in slow-motion a couple of times, we can notice that the following animations take place:
- Top segment rotates by 5/4 π radians, its length shortens and its position changes.
- Middle segment rotates by π radians and shortens slightly.
- Bottom segment rotates by 3/4 π radians, its length shortens and its position changes.
We'll represent each segment as a CAShapeLayer
. At the same time we'll declare some necessary metrics:
class HamburgerButton: UIButton {
let top: CAShapeLayer = CAShapeLayer()
let middle: CAShapeLayer = CAShapeLayer()
let bottom: CAShapeLayer = CAShapeLayer()
let width: CGFloat = 18
let height: CGFloat = 16
let topYPosition: CGFloat = 2
let middleYPosition: CGFloat = 7
let bottomYPosition: CGFloat = 12
}
Each layer's path
will be simply a straight line and all layers will be styled in the same way:
let path = UIBezierPath()
path.moveToPoint(CGPoint(x: 0, y: 0))
path.addLineToPoint(CGPoint(x: width, y: 0))
shapeLayer.path = path.CGPath
shapeLayer.lineWidth = 2
shapeLayer.strokeColor = UIColor.whiteColor().CGColor
CAShapeLayer
doesn't derive its bounds
from the underlying path
, so we have to set it by hand. We'll also disable implicit animations for transform
and position
keys:
let strokingPath = CGPathCreateCopyByStrokingPath(shapeLayer.path, nil, shapeLayer.lineWidth, kCGLineCapButt, kCGLineJoinMiter, shapeLayer.miterLimit)
shapeLayer.bounds = CGPathGetPathBoundingBox(strokingPath)
shapeLayer.actions = [
"transform": NSNull(),
"position": NSNull()
]
What's left is setting up initial positions:
let widthMiddle = width / 2
top.position = CGPoint(x: widthMiddle, y: topYPosition)
middle.position = CGPoint(x: widthMiddle, y: middleYPosition)
bottom.position = CGPoint(x: widthMiddle, y: bottomYPosition)
Animations
All the animations happen simultaneously, have the same durations and timing functions, so we can simply wrap them in a CATransaction
, as follows:
CATransaction.begin()
CATransaction.setAnimationDuration(0.4)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.4, 0.0, 0.2, 1.0))
// animations will go in here
CATransaction.commit()
Middle Segment's Animation
We'll start with the middle segment's animation, because it's the most straightforward one. We'll rotate it and change its strokeEnd
value. We don't have to create a CABasicAnimation
instance to animate strokeEnd
, because it's going to be animated implicitly:
let middleRotation = CAKeyframeAnimation(keyPath: "transform")
middleRotation.values = rotationValuesFromTransform(middle.transform,
endValue: showsMenu ? CGFloat(-M_PI) : CGFloat(M_PI))
middle.ahk_applyKeyframeValuesAnimation(middleRotation)
middle.strokeEnd = showsMenu ? 1.0 : 0.85
To keep the core animation code concise, we'll extract four reusable methods and functions (you can see them on GitHub):
ahk_applyKeyframeValuesAnimation:
andahk_applyKeyframePathAnimation:endValue:
simply add the animation to the layer and set the model's value properly.rotationValuesFromTransform
takes atransform
andendValue
, and returns an array with four rotation transforms2, two of which have values interpolated linearly between0
andendValue
.quadBezierCurveFromPoint
returns aUIBezierPath
consisting of one quadratic curve.
Top Segment's Animation
Now, we're ready to tackle top's
animation. We'll use pretty much the same code as above to rotate it and change its strokeStart
value:
let topRotation = CAKeyframeAnimation(keyPath: "transform")
topRotation.values = rotationValuesFromTransform(top.transform,
endValue: showsMenu ? CGFloat(-M_PI - M_PI_4) : CGFloat(M_PI + M_PI_4))
top.ahk_applyKeyframeValuesAnimation(topRotation)
top.strokeStart = showsMenu ? 0.0 : 0.3
Here's what we'll get if we also move top
to the bottom in a straight line:
The first and the last frames look fine, but the transition is incorrect. It's easy to notice that top's
position in the original animation doesn't change along a straight line, but along a curve. After analyzing the original animation frame by frame we notice that it moves along a quadratic Bézier curve. We'll use CAKeyframeAnimation
's path
property to animate top's
position accordingly:
let positionPathControlPointY = bottomYPosition / 2
let verticalOffsetInRotatedState: CGFloat = 0.75
let topPositionEndPoint = CGPoint(x: width / 2, y: showsMenu ? topYPosition : bottomYPosition + verticalOffsetInRotatedState)
let topPosition = CAKeyframeAnimation(keyPath: "position")
topPosition.path = quadBezierCurveFromPoint(top.position,
toPoint: topPositionEndPoint,
controlPoint: CGPoint(x: width, y: positionPathControlPointY)).CGPath
top.ahk_applyKeyframePathAnimation(topPosition, endValue: NSValue(CGPoint: topPositionEndPoint))
We could now analogously animate bottom
, but I'm going to skip that part here.
Details
Everything looks great, except for the one thing — close to the animation's end, segments don't form the arrow's tip nicely:
To fix that we can slightly adjust rotation's timing via keyTimes
property:
topRotation.calculationMode = kCAAnimationCubic
topRotation.keyTimes = [0.0, 0.33, 0.73, 1.0]
With this change in place our animation is finally production-ready:
You can see the whole project on GitHub. Let me know on Twitter or by email if you have any questions or suggestions.
Disclaimer: Some parts of the implementation have been inspired by, previously mentioned, Robert Böhnke's post.
-
On the other hand, session 211 from 2014's WWDC sports a nice explanation of why you should not use hamburger menus at all. ↩
-
I initially used three values, but later on changed it to four, because I needed to adjust
keyTimes
with more granularity. ↩