In “CAShapeLayer in Depth, Part I,” we explored the creation and configuration of shape layers, looking at each of their properties in turn. While useful as a static shape drawing tool, CAShapeLayer was built to be a powerful, easy-to-use animation class as well. Now, in Part II of “CAShapeLayer in Depth,” we’ll take a close look at each of CAShapeLayer’s animatable properties.

The primary advantage of using shape layers for animation in place of using something like Core Graphics is that CAShapeLayer is composited on the GPU, making it significantly faster. In fact, like practically every Core Animation animation, once a shape layer animation is committed from the main thread of an application, Core Animation does all the heavy lifting inside its own processes, leaving an application’s main thread completely free during the animation. Animating with Core Graphics, on the other hand, might require an application to render a bitmap for every frame of the animation, sending each bitmap to Core Animation to composite along with the rest of the layer hierarchy.1

As a reminder, Part II of this series will be focused on the basics of shape layer animating. Each example will be as simple as possible in order to clearly demonstrate the effects of animating each property. In the last part of this series, Part III, we’ll look at a few examples of non-trivial, real-world applications of shape layers.

Animatable Properties

A little over half of CAShapeLayer’s properties are animatable: path, fillColor, lineDashPhase, lineWidth, miterLimit, strokeColor, strokeStart, and strokeEnd.

Path

Path animations are by far the most powerful and most complex aspects of CAShapeLayer. Path animations allow you to morph a shape layer from one shape to another, which can create very compelling UI effects if used carefully. CAShapeLayer has the following to say about animations on the path property:

Paths will interpolate as a linear blend of the “on-line” points; “off-line” points may be interpolated non-linearly (e.g. to preserve continuity of the curve’s derivative).

What this means is that “on-line” points—those that are explicitly specified as part of a path’s description—are interpolated by moving them in a straight line from their starting positions to their ending positions. On the other hand, “off-line” points—those that are calculated or inferred as intermediary points between “on-line” points—are potentially interpolated using more complex means, the details of which are not made available to us.

Animating between two different paths

Here’s the code that produces this animation:

let starPath = UIBezierPath()
starPath.move(to: CGPoint(x: 81.5, y: 7.0))
starPath.addLine(to: CGPoint(x: 101.07, y: 63.86))
starPath.addLine(to: CGPoint(x: 163.0, y: 64.29))
starPath.addLine(to: CGPoint(x: 113.16, y: 99.87))
starPath.addLine(to: CGPoint(x: 131.87, y: 157.0))
starPath.addLine(to: CGPoint(x: 81.5, y: 122.13))
starPath.addLine(to: CGPoint(x: 31.13, y: 157.0))
starPath.addLine(to: CGPoint(x: 49.84, y: 99.87))
starPath.addLine(to: CGPoint(x: 0.0, y: 64.29))
starPath.addLine(to: CGPoint(x: 61.93, y: 63.86))
starPath.addLine(to: CGPoint(x: 81.5, y: 7.0))

let rectanglePath = UIBezierPath()
rectanglePath.move(to: CGPoint(x: 81.5, y: 7.0))
rectanglePath.addLine(to: CGPoint(x: 163.0, y: 7.0))
rectanglePath.addLine(to: CGPoint(x: 163.0, y: 82.0))
rectanglePath.addLine(to: CGPoint(x: 163.0, y: 157.0))
rectanglePath.addLine(to: CGPoint(x: 163.0, y: 157.0))
rectanglePath.addLine(to: CGPoint(x: 82.0, y: 157.0))
rectanglePath.addLine(to: CGPoint(x: 0.0, y: 157.0))
rectanglePath.addLine(to: CGPoint(x: 0.0, y: 157.0))
rectanglePath.addLine(to: CGPoint(x: 0.0, y: 82.0))
rectanglePath.addLine(to: CGPoint(x: 0.0, y: 7.0))
rectanglePath.addLine(to: CGPoint(x: 81.5, y: 7.0))

// Set an initial path
shapeLayer.path = starPath.cgPath

let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.toValue = rectanglePath.cgPath
pathAnimation.duration = 0.75
pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
pathAnimation.autoreverses = true
pathAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(pathAnimation, forKey: "pathAnimation")
Animating a shape layer's path

It’s pretty obvious that the animation itself is trivial: all it needs is another CGPath to animate to. The hard part is constructing the right paths so the animation looks appealing. Now consider what happens when we simplify the rectangle path; after all, a rectangle can easily be represented by a path with only four unique points:2

let squarePath = UIBezierPath()
squarePath.move(to: CGPoint(x: 0.0, y: 7.0))
squarePath.addLine(to: CGPoint(x: 163.0, y: 7.0))
squarePath.addLine(to: CGPoint(x: 163.0, y: 157.0))
squarePath.addLine(to: CGPoint(x: 0.0, y: 157.0))
squarePath.addLine(to: CGPoint(x: 0.0, y: 7.0))

And here’s what animating to that path looks like:3

Animating to a path with a differing number of segments

This will almost always result in an undesirable animation. Per the documentation for CAShapeLayer’s path property, the result of a path animation is undefined4 if the path being animated to has a different number of control points or segments. In this case, the second path has fewer line segments, so Core Animation actually doesn’t know what to do with the remaining line segments from the original path.5

As a general rule of thumb, your initial and animatable paths should always contain the same number of points. To make this easier while designing your shapes,6 start with the more complex path—the one with the most points or segments—involved with an animation. From there, move the points around to create the simpler shape, placing redundant points wherever they might naturally resolve in a path morphing animation.

Fill Color

Animating a shape’s fill color is straightforward:

shapeLayer.fillColor = UIColor.red.cgColor

let fillColorAnimation = CABasicAnimation(keyPath: "fillColor")
fillColorAnimation.toValue = UIColor.cyan.cgColor
fillColorAnimation.duration = 0.75
fillColorAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
fillColorAnimation.autoreverses = true
fillColorAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(fillColorAnimation, forKey: "fillColorAnimation")
Animating a shape layer's fill color

One thing to keep in mind is that Core Animation always interpolates color in the RGB color space, which is unfortunate but completely understandable given the complexity of converting to better color spaces. RGB is strictly a technical color space used by computers because of the way pixels display color, so the straight line between points when linearly interpolating between two colors in an RGB color cube can pass through arbitrary colors. In the above example, you can see that the fill color appears to transition from red to dark gray to cyan. A better color space can interpolate between hue or luminosity, for example, which might create more visual continuity according to how we perceive color; however, even with a color space like HSB, the results may not look that great with a straight linear interpolation.

It turns out that interpolating between two colors in a way that is visually pleasing to the human eye can be hard and computationally expensive, especially for real-time rendering, but it can be done. For practical purposes, if a straight RGB interpolation between two colors doesn’t produce acceptable results, you may instead choose to use a keyframe animation, picking a handful of better in-between colors.

Finally, recall in Part I that UIColor can create a pattern image fill. There’s nothing inherently unique about pattern images versus solid color fills, so you can, of course, animate to or from a pattern image as well.

Line Dash Phase

Line dash phase, or dash offset, is how far into the dash pattern the line actually starts. Animating this property gives you the “marching ants” effect:

shapeLayer.lineDashPattern = [5]

let lineDashPhaseAnimation = CABasicAnimation(keyPath: "lineDashPhase")
lineDashPhaseAnimation.byValue = 10.0
lineDashPhaseAnimation.duration = 0.75
lineDashPhaseAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
lineDashPhaseAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(lineDashPhaseAnimation, forKey: "lineDashPhaseAnimation")
Animating a shape layer's line dash phase

To ensure that a dash phase animation loops seamlessly, the animation value should be twice the dash phase pattern if there’s only one element in the array and the sum of the elements otherwise.7

let dashPattern = [5, 4, 3, 2]
let dashPhase = dashPattern?.map( { return $0.floatValue } ).reduce(0, { $0 + $1 })

shapeLayer.lineDashPattern = dashPattern

let lineDashPhaseAnimation = CABasicAnimation(keyPath: "lineDashPhase")
lineDashPhaseAnimation.byValue = dashPhase
lineDashPhaseAnimation.duration = 0.75
lineDashPhaseAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
lineDashPhaseAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(lineDashPhaseAnimation, forKey: "lineDashPhaseAnimation")
Ensuring a seamless line dash phase animation

Though the dash phase animation is seamless, you will often see a seam at the start of the line itself. If the sum of the dash pattern array (or twice its value if there is only one element) does not evenly divide the total unit length of the shape’s path, there will be a discontinuity that can create a noticeable seam. There is no built-in method for determining the length of an arbitrary path, and the process of doing so is not trivial. Fortunately, there appears to be an open source solution available specifically for CGPaths. In practice, for non-programmatically generated shape paths, it’s often easy enough to tweak the dash pattern until it repeats evenly.

Line Width

Line width is another straightforward property to animate:

shapeLayer.lineWidth = 0.0

let lineWidthAnimation = CABasicAnimation(keyPath: "lineWidth")
lineWidthAnimation.toValue = 10.0
lineWidthAnimation.duration = 1.5
lineWidthAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
lineWidthAnimation.autoreverses = true
lineWidthAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(lineWidthAnimation, forKey: "lineWidthAnimation")
Animating a shape layer's line width

Recall that path strokes straddle the bounds of the path itself, so half the line width will be inside the path and half will be outside.

Miter Limit

Among all the animatable properties, miter limit is the most perplexing. Briefly, miter limit determines when a miter-style line join switches to a bevel join instead. And while Core Animation does support miterLimit as an animatable property, it actually performs no animation at all. The change to pertinent line joins happens immediately, making it a seemingly-useless property to animate. All that comes to mind is using a miter limit animation in conjunction with other shape layer animations to switch how certain lines are joined mid-animation.

shapeLayer.miterLimit = 10.0

let miterLimitAnimation = CABasicAnimation(keyPath: "miterLimit")
miterLimitAnimation.toValue = 0.0
miterLimitAnimation.duration = 0.75
miterLimitAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
miterLimitAnimation.autoreverses = true
miterLimitAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(miterLimitAnimation, forKey: "miterLimitAnimation")
"Animating" a shape layer's miter limit

Stroke Color

Similar to fill color, animating stroke color is easy:

shapeLayer.strokeColor = UIColor.red.cgColor

let strokeColorAnimation = CABasicAnimation(keyPath: "strokeColor")
strokeColorAnimation.toValue = UIColor.cyan.cgColor
strokeColorAnimation.duration = 0.75
strokeColorAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
strokeColorAnimation.autoreverses = true
strokeColorAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(strokeColorAnimation, forKey: "strokeColorAnimation")
Animating a shape layer's stroke color

Stroke Start and End

A very useful pair of animatable properties is strokeStart and strokeEnd. Both represent a relative point along the total path length, defined as a fraction between 0.0 and 1.0, where 0.0 indicates the beginning of the path, and 1.0 indicates the end of the path. The visible portion of the path is always the difference between strokeEnd and strokeStart.

Animating strokeEnd from 0.0 to 1.0 is commonly used to “draw on” a path:

shapeLayer.strokeStart = 0.0
shapeLayer.strokeEnd = 0.0

let strokeEndAnimation = CABasicAnimation(keyPath: "strokeEnd")
strokeEndAnimation.toValue = 1.0
strokeEndAnimation.duration = 0.75
strokeEndAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
strokeEndAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(strokeEndAnimation, forKey: "strokeEndAnimation")
Animating a shape layer's stroke end

Contrariwise, animating strokeStart from 0.0 to 1.0 is commonly used to “erase” a path:

shapeLayer.strokeStart = 0.0
shapeLayer.strokeEnd = 1.0

let strokeStartAnimation = CABasicAnimation(keyPath: "strokeStart")
strokeStartAnimation.toValue = 1.0
strokeStartAnimation.duration = 0.75
strokeStartAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
strokeStartAnimation.repeatCount = .greatestFiniteMagnitude

shapeLayer.add(strokeStartAnimation, forKey: "strokeStartAnimation")
Animating a shape layer's stroke start

One thing to keep in mind is that both strokeStart and strokeEnd are clamped to the range [0.0, 1.0]. That is, you can’t use negative values to reverse the direction of a stroke animation. To do so, just reverse the path itself using UIBezierPath’s reversing() function.

Finally, CAShapeLayer is happy to animate the stroke of any path, regardless of its complexity. Just remember that it always animates the stroke in the order the segments or curves are added to the path. To that end, it’s not possible to animate different parts of a path concurrently. If, for example, you wanted multiple parts of a path to start drawing on together at the same time, you would need to create different shape layers for each path you wanted and animate them independently.

Summary

CAShapeLayer is a very powerful tool for creating rich, performant shapes with a variety of properties to animate. The utility of some—miterLimit—is questionable, while the absence of others—lineCap, lineDashPattern, and lineJoin—is a little disappointing. Still, especially given iOS 7’s ushered-in era of flatter design, basic shapes adorn much of modern app UI design. Morphing shapes, changing stroke line widths, and animating stroke start and end points are great ways to create compelling visual effects, and GPU-compositing of shapes makes it fast to include many of them at once in your designs without affecting render performance too adversely.

In the final part of this series on CAShapeLayer, we’ll look at some interesting combinations of the properties discussed in the past two posts to create some neat designs and animations.

  1. It is possible to prevent main thread-bound drawing operations by performing them asynchronously on a background thread, but this can add overhead and synchronization complexity to complex drawings. Even still, each bitmap will have to be uploaded to Core Animation every frame in order to synchronize the animation with the main run loop. 

  2. Most paths will want a redundant point to draw a line back to the beginning, which means you’ll always see an extra CGPoint in your path code. You can actually call close() on the path to automatically finish it without explicitly drawing a line to the beginning, it always draws a straight line between the last point moved to and the initial point, which may not be what you want. 

  3. While I explicitly matched the SVG animation above to how Core Animation would render it, the results would likely be different depending on whatever the animation framework does when given an incongruent destination path. In most cases, though, the results would not be desirable. 

  4. In fact, based on my own observation, it appears that CAShapeLayer interpolates between points in the same order that they’re added to the path, and missing points are animated to/from (0, 0). So while you can probably reason what the incongruent path animation would look like, you shouldn’t rely on this behavior. 

  5. One of the best solutions I’ve seen for this problem, albeit only for the web, is GreenSock’s MorphSVG plugin. Regardless of the number of control points or segments, it create very impressive path animations. In my opinion, Core Animation desperately needs a comparable feature to make shape layer paths less frustrating to animate. 

  6. My tool of choice is PaintCode, which is a solid vector graphics and code generation app. My experiments with Adobe Illustrator were not very good, as it appears to export strange SVGs despite manually moving the same points around. 

  7. A dash pattern with only one value would still produce a solid line, so Core Animation implicitly uses the same value for the unpainted segments. 

CALayer