Hamburger Button Animation

This article uses Swift 1.0.

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:

  1. Top segment rotates by 5/4 π radians, its length shortens and its position changes.
  2. Middle segment rotates by π radians and shortens slightly.
  3. 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: and ahk_applyKeyframePathAnimation:endValue: simply add the animation to the layer and set the model's value properly.
  • rotationValuesFromTransform takes a transform and endValue, and returns an array with four rotation transforms2, two of which have values interpolated linearly between 0 and endValue.
  • quadBezierCurveFromPoint returns a UIBezierPath 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.


  1. On the other hand, session 211 from 2014's WWDC sports a nice explanation of why you should not use hamburger menus at all. 

  2. I initially used three values, but later on changed it to four, because I needed to adjust keyTimes with more granularity.