Prototyping Animations in Swift
One of my favorite things about building mobile applications is bringing a designer’s creation to life. Being able to harness the power of the iPhone and create experiences that delight users is one of the reasons I wanted to become an iOS developer. So when the design team at s23NYC came to me with the animation prototype for SNKRS Pass I was excited but also very scared:
Where to begin? It can be a daunting question when looking at a complex animation mock-up. In this blog post we will break down an animation into pieces and prototype iteratively to build a reusable, animated wave view.
Prototyping in a Playground
Before we dive in, it will be useful to setup an environment where we can rapidly prototype our animation without having to continuously build and run with every small change we make. Luckily, Apple gave us Swift Playgrounds, which is the perfect place to sketch out front-end code quickly without having to use a full application container.
Let’s create a new Playground in Xcode by selecting File > New > Playground… from the menu bar. We can choose the Single View playground template to get the Playground live view code written for us in a nice template. We’ll make sure to select the Assistant Editor so that we can see live updates as we code.
This animation we’re building is one of the final parts of the SNKRS Pass experience, which is a new way to reserve access to the newest and hottest Nike shoes at retail stores. When a user goes to pick up their shoes, we want to give them a digital pass that feels like a golden ticket. The background animation is meant to mimic a holographic sticker of authenticity. When the user tilts the device, the animation reacts, and moves around as if a light is reflecting off of it.
Let’s start very simply by creating some concentric circles:
Easy enough. Now how to animate these outwards? We’ll make use of CAAnimation and Timer to continuously add and animate these CAShapes. There are two parts to this animation: scaling the shape’s path and increasing the shape’s bounds. It’s important to animate the bounds in tandem with a scale transform on the shape so that the circle moves to fill the screen. If we don’t animate the bounds, the circles would expand while keeping their initial origin at the center of the view (expanding down to the lower right corner). So let’s add both of these animations to an animation group to perform them at the same time. It’s important to remember that CAShape and CAAnimation require converting UIKit values to their CGPath and CGColor counter parts. Otherwise, the animation will just fail silently! We’ll also make use of the CAAnimation delegate method animationDidStop to remove the shape layer from the view once its animation completes.
Trippy! Next, we will swap out the circles for our custom path. In order to generate a custom path, we can use PaintCode to help generate code. In this blog post, we’ll be using a star shape for our wave path:
The tricky part with using a custom path is that we now need to scale this path instead of generating a final circle path from the bounds of the AnimatedWaveView. Since we want this view to be reusable, we need to calculate how much to scale the shape’s path and bounds based on the final destination rect. We can create a CGAffineTransform based on the ratio of the path’s final bounds to its initial bounds. We also multiply this ratio by the scaleFactor of 2.25 so that the path expands larger than the view before completing. We need to do this so that the shape completely fills the corners of our view, rather than just disappearing once it reaches the view’s size. Let’s build the initial and final paths during initialization and update the final path if our view’s frame changes:
After updating our animation group to use our new finalPath property and using the initialPath inside buildWave(), we’ll have an updated path animation:
The final piece to making sure we can reuse this wave animation in different sizes is to refactor the Timer approach. Rather than continuously creating new waves, we can create all of the waves at once and stagger the start time on CAAnimation. This is done by setting the timeOffset on the CAAnimation group. By giving each animation group a slightly different timeOffset, we run all animations in parallel from different starting points. We will calculate the offset by dividing the total duration of the animation by the number of waves on screen:
We’ll pass the duration and timeOffset down to the animateWave() method. Let’s add in a fade-in animation as part of the group to make things a bit smoother:
Now we can draw each waves and add animations all at once when calling makeWaves(). Let’s take a look at our hard work:
Woohoo! We now have a reusable animated wave view!
Adding a Gradient
The next step is to improve our wave animation by adding a gradient. We’d also like to animate the gradient in relation to device motion, so we will create a gradient layer and keep a reference to it. I explored putting semi-transparent wave layers on top of the gradient but the best solution was to add the wave layers to a parent layer and set it as the mask of the gradient layer. With this approach, the parent layer draws the gradient itself, which looks much more effective:
The next step is animating the gradient to move in correlation with device motion tracking. We want to create a holographic effect that mimics light reflecting against the surface of the view as you tilt it in your hands. To achieve this, we will add a gradient which rotates around the center of the view. We will use CoreMotion and the CMMotionManager to track accelerometer updates and use this data for our interactive animation. NSHipster has a fantastic write-up on CMDeviceMotion if you’re looking for a deeper dive into what CoreMotion has to offer. For our AnimatedWaveView, we will only need the CMDeviceMotion gravity property (CMAcceleration) which will give us the acceleration velocity of the device. We will only need to track the X and Y axis as the user tilts the device horizontally and vertically:
So X and Y will be from -1 to +1 with (0,0) being the origin (device resting flat on a table face up). Now how do we want to use this data?
At first, I tried using CAGradientLayer and thought rotating the gradient would create this shimmering effect. We could update its startPoint and endPoint based on the CMDeviceMotion gravity. CAGradientLayer is a linear gradient, so revolving startPoint and endPoint around the center will effectively rotate the gradient. Let’s convert the X and Y values from gravity to the degree value we would use to rotate the gradient:
Note: We can’t simulate motion tracking in the simulator or in a Playground so we’ll need to switch to working in a Xcode project with a real device.
After some initial testing with design, we felt the need to add a booster to the X value returned from gravity so that the gradient would rotate at a faster rate. So we multiply the gravity.x before converting to radians.
To perform the rotation of the gradient, we’ll need to convert the angle at which the device is rotated to the start and end points of the rotation arc: startPoint and endPoint for the gradient. There is a really smart StackOverflow answer we can use to achieve this:
Busting out some trigonometry! Now we’ve converted degrees to our new startPoint and endPoint.
This is ok…but can we do better? Most definitely! Let’s take this to the next level…
CAGradientLayer doesn’t support radial gradients…but that doesn’t mean it can’t be done! We can use CGGradient to create our own CALayer class, RadialGradientLayer. The tricky part here is making sure to cast an array of CGColors to a CFArray during the initialization of the CGGradient. It took a bit of trial and error to figure out exactly what kind of array needed to be casted to a CFArray and that locations could simply be an array of CGFloats to satisfy the UnsafePointer<CGFloat>? Type.
At last we have all the pieces in place! Now we can swap out the CAGradientLayer for our shiny new RadialGradientLayer and calculate a mapping of device gravity X and Y to a coordinate position for the gradient. We’ll convert the gravity values to a float percentage between 0.0 and 1.0 to calculate how to move the gradient.
Now let’s circle back to the makeWaves and addGradientLayer method and make sure everything is connected together:
Now that’s pretty slick!
Attached is the the final example project with all of the code in its final state. I encourage you to try running this on a device and play around with it!