Animating a Jumping Pin
I recently read a fantastic article on how to animate a really slick dribble animation using Android’s AnimatedVectorDrawable
. This inspired me to see how it might look when implemented using Core Animation.
Image may be NSFW.
Clik here to view.
It’s been a while since I’ve done much Core Animation, so it was an excellent exercise for me to dust of my rusty skills and see how various techniques play together.
The animation above is comprised of several distinct components:
- The jumping pin
- The scrolling dots
- Fading in/out
This article describes the techniques I used to implement each of these.
Jumping pin
The author (Nick Butcher) of the Android article did an excellent job of describing how the pin is drawn as a five SVG paths. Essentially, the animation can be modelled as a series of path
interpolations.
In one of the gists he links to in his post, Nick has conveniently defined each of the constituent paths. For example, the SVG path for the first frame looks like:
M 412.7 238.9 C 381 238.9 356.8 261.8 356.8 294.6 C 356.8 329.7 407.7 408.7 409.2 408.8 C 410.7 408.9 469.4 328.1 469.4 294.6 C 469.4 261.8 444.5 238.9 412.7 238.9 Z M 412.7 324.3 C 397.2 324.3 384.7 311.3 384.7 295.3 C 384.7 279.3 397.2 266.3 412.7 266.3 C 428.2 266.3 440.8 279.3 440.8 295.3 C 440.8 311.3 428.2 324.3 412.7 324.3 Z
I started manually converting this into a sequence of UIBezierPath
commands, however that quickly turned frustrating and error prone. I discovered an awesome site by Mike Engel that can take an SVG path and output the equivalent UIBezierPath
commands. For example, the output for the SVG path above is:
let pinPath1 = UIBezierPath() pinPath1.move(to: CGPoint(x: 412.7, y: 238.9)) pinPath1.addCurve(to: CGPoint(x: 356.8, y: 294.6), controlPoint1: CGPoint(x: 381, y: 238.9), controlPoint2: CGPoint(x: 356.8, y: 261.8)) pinPath1.addCurve(to: CGPoint(x: 409.2, y: 408.8), controlPoint1: CGPoint(x: 356.8, y: 329.7), controlPoint2: CGPoint(x: 407.7, y: 408.7)) pinPath1.addCurve(to: CGPoint(x: 469.4, y: 294.6), controlPoint1: CGPoint(x: 410.7, y: 408.9), controlPoint2: CGPoint(x: 469.4, y: 328.1)) pinPath1.addCurve(to: CGPoint(x: 412.7, y: 238.9), controlPoint1: CGPoint(x: 469.4, y: 261.8), controlPoint2: CGPoint(x: 444.5, y: 238.9)) pinPath1.close() pinPath1.move(to: CGPoint(x: 412.7, y: 324.3)) pinPath1.addCurve(to: CGPoint(x: 384.7, y: 295.3), controlPoint1: CGPoint(x: 397.2, y: 324.3), controlPoint2: CGPoint(x: 384.7, y: 311.3)) pinPath1.addCurve(to: CGPoint(x: 412.7, y: 266.3), controlPoint1: CGPoint(x: 384.7, y: 279.3), controlPoint2: CGPoint(x: 397.2, y: 266.3)) pinPath1.addCurve(to: CGPoint(x: 440.8, y: 295.3), controlPoint1: CGPoint(x: 428.2, y: 266.3), controlPoint2: CGPoint(x: 440.8, y: 279.3)) pinPath1.addCurve(to: CGPoint(x: 412.7, y: 324.3), controlPoint1: CGPoint(x: 440.8, y: 311.3), controlPoint2: CGPoint(x: 428.2, y: 324.3)) pinPath1.close()
Using that tool, I was easily able to create 5 UIBezierPath
s that represent each point in the pin animation. Creating the animation looks like:
let pin = CAShapeLayer() pin.fillColor = UIColor.red.cgColor pin.path = pinPath1.cgPath mainLayer.add(pin) let pinJump = CAKeyframeAnimation(keyPath: "path") pinJump.values = [ pinPath1.cgPath, pinPath2.cgPath, pinPath3.cgPath, pinPath4.cgPath, pinPath5.cgPath, pinPath1.cgPath, ] pinJump.duration = 1.0 pinJump.repeatCount = Float.infinity pin.add(pinJump, forKey: "jump")
Note that we include pinPath1
at both the start and end of the values
array. If we do not do this, then when the animation restarts, it will “snap” from the 5th position back to the 1st which looks jerky and horrible. So, instead we animate it back to the starting position which means the restart begins from the same position giving us the smooth transition we seek.
Scrolling Dots
Firstly, let’s deal with the line that sits underneath the dots. It can be represented using a simple CAShapeLayer
line:
let lineRect = CGRect(x: 0, y: baseLine, width: width, height: 1) let line = CAShapeLayer() line.path = UIBezierPath(rect: lineRect).cgPath line.strokeColor = dotColour.cgColor mainLayer.addSublayer(line)
In terms of the dots themselves, the technique that the original article presented is quite clever in that it creates a single view with 5 dots evenly spaced horizontally, and once the 4th dot arrives at the original location of the 1st dot, the animation is reset and starts again.
As it turns out, we can actually achieve the same effect by creating a container view with just four dots evenly distributed at 25%, 50%, 75% and 100% respectively. By creating the containing view at twice the width of the main viewport, there will only ever be (at most) three dots on the main viewport at once.
Once each dot has been added to a containing layer, we can animate the containing layer from right to left.
let frame = CGRect(x: width, y: baseLine, width: width*2, height: height) let dots = CALayer() dots.frame = frame dots.addSublayer(dot1) dots.addSublayer(dot2) dots.addSublayer(dot3) dots.addSublayer(dot4) mainLayer.addSublayer(dots) let moveDots = CABasicAnimation(keyPath: "position") moveDots.fromValue = dots.frame moveDots.toValue = CGPoint(x: -dots.frame.width*0.25, y: baseLine) moveDots.duration = 3.0 moveDots.repeatCount = Float.infinity dots.add(moveDots, forKey: "move dots from right to left")
Fading In and Out
The last thing to add is the fading in and out on the edges. I chose to implement this using a simple transparent→white gradient layer spanning from top-to-bottom and 50 points wide. The line and dots just slide underneath this layer.
let leftFade = CAGradientLayer() leftFade.frame = CGRect(x: 0, y: 0, width: 50, height: height) leftFade.colors = [ UIColor.white.withAlphaComponent(0).cgColor, UIColor.white.withAlphaComponent(1).cgColor ] leftFade.startPoint = CGPoint(x: 1, y: 1) leftFade.endPoint = CGPoint(x: 0, y: 1) let rightFade = CAGradientLayer() rightFade.frame = CGRect(x: width-50, y: 0, width: 50, height: height) rightFade.colors = [ UIColor.white.withAlphaComponent(0).cgColor, UIColor.white.withAlphaComponent(1).cgColor ] rightFade.startPoint = CGPoint(x: 0, y: 1) rightFade.endPoint = CGPoint(x: 1, y: 1) mainLayer.addSublayer(leftFade) mainLayer.addSublayer(rightFade)
Conclusion
Core Animation offers an amazing amount of functionality out of the box. Looking back, the two most challenging pieces of this exercise for me were:
- Deciding on which type of animations to use and how to link them together. I did consider using some more sophisticated keyframe animations to tie the timings together, however, as it turns out, judicious use of the
duration
parameter meant that I didn’t need to go to the trouble of meticulous keyframe timings. - The animation of the dots to appear as one continuous stream of dots is trickier than it looks. The specific location of the dots within the frame is extremely important to this animation appearing smooth. An alternative implementation would be to animate the dots individually dots and use an animation group to control the individual delays.
I’d love to hear feedback on where I could do things better/more effectively. Please feel free to email me or contact via twitter.
An Xcode 8 playground containing the full source can be found on github.