How to build a nice Hamburger Button transition in Swift

Hamburger buttons may have become somewhat of a cliché in interface design lately, but when I came across a particularly nice transition of a hamburger button on dribbble, I had to try and recreate it in code.

Here’s the original shot by the CreativeDash team:

You’ll notice how the top and bottom strokes of the hamburger form a X, while the middle one morphs into an outline. I knew this effect could be recreated using CAShapeLayer, but first I had to create a CGPath for each of the three strokes.

The short, straight strokes can be figured out manually:

let shortStroke: CGPath = {
    let path = CGPathCreateMutable()
    CGPathMoveToPoint(path, nil, 2, 2)
    CGPathAddLineToPoint(path, nil, 28, 2)

    return path
}()

For the middle stroke however, I created a path in Sketch that starts from the middle and seamlessly transitions into the outline.

I then exported this path as an SVG file and imported it into the old PaintCode 1, which converted the file into a code snippet that created a UIBezierPath. I then rewrote said piece of code into the following instructions that create the desired CGPath object:

let outline: CGPath = {
    let path = CGPathCreateMutable()
    CGPathMoveToPoint(path, nil, 10, 27)
    CGPathAddCurveToPoint(path, nil, 12.00, 27.00, 28.02, 27.00, 40, 27)
    CGPathAddCurveToPoint(path, nil, 55.92, 27.00, 50.47,  2.00, 27,  2)
    CGPathAddCurveToPoint(path, nil, 13.16,  2.00,  2.00, 13.16,  2, 27)
    CGPathAddCurveToPoint(path, nil,  2.00, 40.84, 13.16, 52.00, 27, 52)
    CGPathAddCurveToPoint(path, nil, 40.84, 52.00, 52.00, 40.84, 52, 27)
    CGPathAddCurveToPoint(path, nil, 52.00, 13.16, 42.39,  2.00, 27,  2)
    CGPathAddCurveToPoint(path, nil, 13.16,  2.00,  2.00, 13.16,  2, 27)

    return path
}()

There probably is a library that allows you to load CGPaths straight from SVG files, but for a short path like this one, having it in code is not a big deal.

In my UIButton subclass, I added three CAShapeLayer properties and set the their paths accordingly:

self.top.path = shortStroke
self.middle.path = outline
self.bottom.path = shortStroke

Then I styled all three of them like so:

layer.fillColor = nil
layer.strokeColor = UIColor.whiteColor().CGColor
layer.lineWidth = 4
layer.miterLimit = 4
layer.lineCap = kCALineCapRound
layer.masksToBounds = true

In order to calculate their bounds correctly, I needed take the size of the stroke into account. Thankfully, CGPathCreateCopyByStrokingPath will create a path that follows the outlines of the original stroke, therefore its bounding box will then fully contain the contents of the CAShapeLayer:

let boundingPath = CGPathCreateCopyByStrokingPath(layer.path, nil, 4, kCGLineCapRound, kCGLineJoinMiter, 4)

layer.bounds = CGPathGetPathBoundingBox(boundingPath)

Since the outer strokes will later rotate around their right-most point, I had to set their anchorPoint accordingly when layouting their layers:

self.top.anchorPoint = CGPointMake(28.0 / 30.0, 0.5)
self.top.position = CGPointMake(40, 18)

self.middle.position = CGPointMake(27, 27)
self.middle.strokeStart = hamburgerStrokeStart
self.middle.strokeEnd = hamburgerStrokeEnd

self.bottom.anchorPoint = CGPointMake(28.0 / 30.0, 0.5)
self.bottom.position = CGPointMake(40, 36)

Now, when the button changes state, it should animate the three strokes to their new positions. Once again, the two outer strokes are easy. For the top stroke, I first moved it inward by 4 points to keep in centered, then rotated it by negative 45 degrees to form one half of the X:

var transform = CATransform3DIdentity
transform = CATransform3DTranslate(transform, -4, 0, 0)
transform = CATransform3DRotate(transform, -M_PI_4, 0, 0, 1)

let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = NSValue(CATransform3D: CATransform3DIdentity)
animation.toValue = NSValue(CATransform3D: transform)
animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, -0.8, 0.75, 1.85)
animation.beginTime = CACurrentMediaTime() + 0.25

self.top.addAnimation(animation, forKey: "transform")
self.top.transform = transform

(The bottom stroke is left as an exercise to the reader.)

Again, the middle stroke is a little trickier. In order to achieve the desired effect, I needed to animate the CAShapeLayer’s strokeStart and strokeEnd properties separately.

First I had to figure out the correct values for the two properties in the two states. Note that the even in its hamburger state, the stroke does not start at 0. By having the path extend slightly beyond the left edge of the outer strokes, we can achieve a nice anticipation effect when applying the timing function later:

let menuStrokeStart: CGFloat = 0.325
let menuStrokeEnd: CGFloat = 0.9

let hamburgerStrokeStart: CGFloat = 0.028
let hamburgerStrokeEnd: CGFloat = 0.111

Now we only need to create the animations and add them to the layer:

let strokeStart = CABasicAnimation(keyPath: "strokeStart")
strokeStart.fromValue = hamburgerStrokeStart
strokeStart.toValue = menuStrokeStart
strokeStart.duration = 0.5
strokeStart.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, -0.4, 0.5, 1)

self.middle.addAnimation(strokeStart, forKey: "strokeStart")
self.middle.strokeStart = menuStrokeStart

let strokeEnd = CABasicAnimation(keyPath: "strokeEnd")
strokeEnd.fromValue = hamburgerStrokeEnd
strokeEnd.toValue = menuStrokeEnd
strokeEnd.duration = 0.6
strokeEnd.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, -0.4, 0.5, 1)

self.middle.addAnimation(strokeEnd, forKey: "strokeEnd")
self.middle.strokeEnd = menuStrokeEnd

Putting everything together, the result looks something like this:

I’m pretty happy with how it turned out. If you are too, you can find the code on GitHub and follow me on Twitter.

Cartography link

If you follow the Apple developer community, you’ve probably heard of Swift by now, Apple’s new programming language for iOS and OS X apps.

Some of Swift’s features, such as operator overloading and implicit closures allow us to create powerful yet typesafe DSLs. For example, I recently released my first Swift open source library, Cartography, that makes use of both of these features.

Inspired by the awesome FLKAutoLayout, Cartography allows you to create Auto Layout constraints in a declarative fashion.

While NSLayoutConstraint supports what they call a visual format string, its syntax is not very intuitive. Additionally, since it is, well, a string, the compiler can’t assist you with validating your expression either. Compare this example I adapted from Apple’s documentation

NSArray *constraint = [NSLayoutConstraint
    constraintsWithVisualFormat:@"[view1]-20-[view2]"
    options:0
    metrics:nil
    views:NSDictionaryOfVariableBindings(view1, view2)];

[view2 addConstraint:constraint];

with the equivalent Cartography code:

layout(view1, view2) { view1, view2 in
    view2.left == view1.right + 20
}

Fixed aspect ratios can’t even be expressed using the visual format string, you would have to use the lower level +constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:​. With Cartography, they are concise and readable:

layout(imageView) { imageView in
    imageView.width == 2 * imageView.height
}

I also managed to created new attributed to constraint multiple attributes at the same time:

layout(view) { view in
    view.size   <= view.superview!.size / 2
    view.center == view.superview!.center
}

As usual, you can find Cartography on GitHub.

RACDC 2014

GitHub hosted RACDC 2014 during WWDC two weeks ago, which was a lot of fun. Justin spoke about the Future of ReactiveCocoa and what to expect in RAC 3.0 here:

Also check out Dave Lee’s talk RACify Non-Reactive Code and Jon Sterling’s Modularity à la Taliban as well as the panel discussion.

Swimming (Patrick Ellis Remix) link

Asterism

I’ve been working on Asterism for quite a while now, but I only recently got around to put up proper documentation.

Asterism is a functional toolbelt that uses overloaded C functions to unify its API. This allows you to use the same method for different data structures and block types:

ASTEach(@[ @"a", @"b", @"c" ], ^(NSString *letter) {
    NSLog(@"%@", letter);
});

ASTEach(@[ @"a", @"b", @"c" ], ^(NSString *letter, NSUInteger index) {
    NSLog(@"%u: %@", index, letter);
});

ASTEach(@{ @"foo": @"bar" }, ^(NSString *key, NSString *value) {
    NSLog(@"%@: %@", key, value);
});

It supports all the usual methods for NSArray, NSDictionary, NSSet and NSOrderedSet.

You can check out Asterism on GitHub and of course, install it through CocoaPods.

objc.io issue 12 link

When Chris, Florian and Daniel started objc.io, I immediately knew I wanted to contribute an article, but it took me 12 months of lurking on the mailing list to find something to write about.

This issue is all about animations, and full of great articles by guest authors Engin Kurutepe, Joachim Bondo, David Rönnqvist, Nick Lockwood and yours truly.

My article serves as an introduction to Core Animation and the different types of animations you can employ to breath live into your app. I had a lot of fun writing it and building the .gifs. Let me know what you think of it on Twitter!.

I’ll end this post with the two songs I’ve snuck into the article, by my friends Seams and Mighty Oaks:

Through the Warp Zone link

Emily wrote a great analysis of how to access the infamous -1 world in Super Mario Bros. Turns out that there is a whole continuum of worlds beyond the usual eight.

There is a lot of creative code reuse in Mario. This served to keep the game very, very small—as all early NES games were. […] Mario would check out what number was above the pipe in a warp zone and plug that number directly into the world to which it sent you.

World 9-1?!

By simply manipulating the label above the warp pipe with a hex editor, you can explore all kinds of alternative levels:

Some strongly resemble existing worlds with little twists (weird gray blocks all over, gray spits from the castles in random places). Some are like world nine, something which should never have seen the light of day and which only barely work (I note that my emulator I used for making screenshots actually froze up). Some are blank and don’t work at all.