15.
Shapes & Masks
Written by Marin Todorov
This chapter marks a bit of a shift in this section of the book: not only are you going to start working with a different sample project, but you’ll work with multi-layer effects, create layer animations that appear to interact physically with each other and morph between shapes as the animation runs.
If that sounds like a lot to take in, just think back to the great-looking animations you created in previous chapters with a relatively small bit of code!
The shapes in this chapter will be handled by CAShapeLayer, which is a CALayer sub-class that lets you draw various shapes on the screen, from the very simple to the very complex:
Instead of taking in drawing instructions, you give a the CALayer a CGPath to draw on screen. This comes in handy since Core Graphics already defines a very extensive API of drawing instructions for building CGPath shapes.
If you’re more familiar with UIBezierPath, you can use that to define a shape and then use its cgPath property to get its Core Graphics representation. You will give that a try later in this very chapter.
After you create your desired shape you can set such properties on as the stroke color, fill color and stroke dash pattern.
Of course, by now you’re likely asking “…but can I animate these properties?” Yes, you can:
-
path: Morph the layer’s shape into a different shape. -
fillColor: Change the fill tint of shape to a different color. -
lineDashPhase: Create a marquee or “marching ants” effect around your shape. -
lineWidth: Grow or shrink the size of the stroke line of your shape.
There are two more animatable properties that you can use when drawing shapes; you’ll learn about these in Chapter 15: “Stroke and Path Animations.”
The project for this chapter simulates the starting screen of a combat game that is searching for an online opponent. You’ll simulate some online communication and add animations to show the communication state.
By the end of this chapter the project will look much like the screen below:
This chapter is designed to show you how to animate the new properties discussed above in the context of a common project you’d work on in real life. This will require a bit of extra work, but I know you’ll enjoy the ride!
Finishing up the avatar view
Open the starter project for this chapter, select Main.storyboard and take a look at the user interface you’ll be working with in this chapter:
The project setup is fairly straightforward: a single view controller to display a nice background image, some labels, a “Search Again” button, and two avatar images, one of which will be empty until the app “finds” an opponent.
The two avatars are each an instance of the class AvatarView. In this section of the chapter, you’ll quickly finish writing the class code while you learn how AvatarView works.
Open AvatarView.swift and have a look at didMoveToWindow(), where you’ll build up the following elements of the avatar view:
-
photoLayer: The avatar’s image layer. -
circleLayer: A shape layer for drawing a circle. -
maskLayer: Another shape layer for drawing a mask. -
label: A label to show the player’s name.
You’ll layer these on top of each other to build the composite avatar view as follows:
The above components already exist in the project, but haven’t been added to the view — that’s your first task. Add the following code to didMoveToWindow():
photoLayer.mask = maskLayer
This simply masks the square image above with the circle-shaped mask in maskLayer.
Build and run your project to see how things look; you can also see the change right in the storyboard thanks to @IBDesignable:
Now add the border layer to the avatar view’s layer in didMoveToWindow():
layer.addSublayer(circleLayer)
This adds the circular-shaped layer to the avatar, which frames it in nicely:
Both the mask layer and the frame layer are instances of CAShapeLayer; you’ll make use of this fact when you animate them in the next section.
There’s one more piece to add — the player name label. Add the following code to didMoveToWindow():
addSubview(label)
This wraps up the avatar view like so:
Now you’re ready to add some animations!
Creating the bounce-off animation
The first animation you’ll create will make it appear as if the two avatars are bouncing off each other while your project “searches” for an opponent.
Open ViewController.swift and add the following line to viewDidAppear():
searchForOpponent()
This method kicks off your searching-for-opponent animation.
Add the code below to ViewController to create the first iteration of this method:
func searchForOpponent() {
let avatarSize = myAvatar.frame.size
let bounceXOffset: CGFloat = avatarSize.width/1.9
let morphSize = CGSize(
width: avatarSize.width * 0.85,
height: avatarSize.height * 1.1)
}
A bit of math is involved in this animation — but not too much! First, you calculate the horizontal distance the avatars should move when they bounce towards each other and save that value in bounceXOffset. You’ll use the morph size later to add an extra effect when the two avatars collide.
Now that you know the x-offset, you can calculate the locations to which the avatars should move. Add the following code to searchForOpponent():
let rightBouncePoint = CGPoint(
x: view.frame.size.width/2.0 + bounceXOffset,
y: myAvatar.center.y)
let leftBouncePoint = CGPoint(
x: view.frame.size.width/2.0 - bounceXOffset,
y: myAvatar.center.y)
When the avatars reach the right and left bounce points, respectively, they will just barely touch each other, at which point you’ll animate them away from each other again.
Finally, add the following code to searchForOpponent():
myAvatar.bounceOff(point: rightBouncePoint,
morphSize: morphSize)
opponentAvatar.bounceOff(point: leftBouncePoint,
morphSize: morphSize)
bounceOff(point: morphSize:) doesn’t yet exist; you’ll add it in just a moment. It takes two parameters: the point to where the avatar should move and the size to which it should morph. That’s all you need to create your animation.
Open AvatarView.swift and add the bounce method below:
func bounceOff(point: CGPoint, morphSize: CGSize) {
let originalCenter = center
UIView.animate(withDuration: animationDuration, delay: 0.0,
usingSpringWithDamping: 0.8, initialSpringVelocity: 0.0,
animations: {
self.center = point
},
completion: { _ in
//complete bounce to
}
)
}
In the above method, you first store the center coordinate of the avatar view; you’ll need this later to animate the view back to its original location. Next, you use a spring animation to move the avatar view to the bouncePoint coordinate.
Now you need something to animate the avatar to its starting location. Add the following code to the end of bounceOff(point: morphSize:):
UIView.animate(withDuration: animationDuration,
delay: animationDuration, usingSpringWithDamping: 0.7,
initialSpringVelocity: 1.0,
animations: {
self.center = originalCenter
},
completion: { _ in
delay(seconds: 0.1) {
self.bounceOff(point: point, morphSize: morphSize)
}
}
)
The above code uses another spring animation to move the avatar back to its original location. After a slight delay, you re-start the animation again from the completion closure.
Build and run your project to see how the bounce animation looks.
Note that when the avatars views touch they stay together for a short period, as if there’s tension building between them. This short period is where you’ll add the “squishing” effect using shape morphing techniques.
Morphing shapes
When the two avatars collide, they should squish a little in this perfectly-elastic collision. The view controller will pass in a morph size that makes the avatar image slightly taller and narrower for this effect:
This will make it look like the avatars are pressing against each other when they meet in the middle of the screen.
The first thing to take care of is the frame to use for the morphing effect. And just to complicate things a little, the frame will be different for each avatar, depending on whether it animates from the left or the right.
Add the following code to the bottom of bounceOff(point: morphSize:):
let morphedFrame = (originalCenter.x > point.x) ?
CGRect(x: 0.0, y: bounds.height - morphSize.height,
width: morphSize.width, height: morphSize.height):
CGRect(x: bounds.width - morphSize.width,
y: bounds.height - morphSize.height,
width: morphSize.width, height: morphSize.height)
If the avatar animates from left to right, then the final position of the morphed avatar will touch the right edge of the original frame. It’s the exact opposite for the avatar animating from right to left, as it will end up on the left edge of the original frame.
This means the avatar images end up touching slightly in the center of the screen, like so:
Finally, you can add the shape-shifting animation code to the bottom of bounceOff(point: morphSize:):
let morphAnimation = CABasicAnimation(keyPath: "path")
morphAnimation.duration = animationDuration
morphAnimation.toValue = UIBezierPath(ovalIn:
morphedFrame).cgPath
morphAnimation.timingFunction = CAMediaTimingFunction(
name: .easeOut)
Here, you create a CABasicAnimation and set its keyPath as the path property. As with any other property layer animation, you only need to set the end value and Core Animation will render the intermediate states for you.
You then set the duration and use the class UIBezierPath to create an oval path for the effect. UIBezierPath is quite handy as it features a number of convenience initializer methods, including the one you used above which takes a CGRect and creates an oval path that fits into the rect.
Finally, you set the animation to ease-out which helps build the tension before the avatars bounce off.
That was a long haul, but all that’s left is to add the animation to the avatar before you can see your crazy shape shifting in action!
Add the following line of code to the bottom of bounceOff(point: morphSize:):
circleLayer.add(morphAnimation, forKey: nil)
Build and run your project to see the end result:
The avatar frames squish neatly against each other and then bounce off, like two angry battling amebae floating in the pre-historic sea!
The effect isn’t quite complete, as only the frames morph, leaving the images underneath unchanged. Recall that you set the mask of the avatar image to be a CAShapeLayer — that’s the same class as the avatar frame. So theoretically you could re-use the animation object on your frame for your mask.
Will theory work in practice? Give it a try, and add the following line to the bottom of bounceOff(point: morphSize:):
maskLayer.add(morphAnimation, forKey: nil)
Build and run your project again; this time you should see both the frame and mask morph in perfect sync:
Awesome — you’ve just created a really nice-looking animation with only a bit of math and a few well-designed chunks of code! In the space of this chapter, you’ve learned how to create and animate shapes and how to use and animate shape layers as masks.
Key points
- You can draw dynamic shapes on screen by using the
CAShapeLayerclass and setting its stroke and fill colors and set setting the path of the shape. - You can animate the shape rendered by
CAShapeLayerby animating itspathproperty. -
CAShapeLayeris a great way to clip the contents of another layer by cutting out and displaying on screen a circle, square, or a star of the underlaying content.
Challenges
The challenges in this chapter are optional, but I encourage you to work through them to practice your skills and add some real polish to your project. However if you’re eager to start with gradient animations, then you can head straight on to the next chapter.
Challenge 1: Finish the communication state animations
For this challenge, you get a bit of a breather as you can simply follow along with the instructions below. Your task in this challenge is to add some status messages to show to the user as your faux “searching for an opponent” task progresses.
Open ViewController.swift and add the following code to the bottom of searchForOpponent():
delay(seconds: 4.0, completion: foundOpponent)
The app will continue to “search” for four seconds before calling foundOpponent(), which will indicate to the player that the app has found an opponent.
Add the following method to the ViewController class:
func foundOpponent() {
status.text = "Connecting..."
opponentAvatar.image = UIImage(named: "avatar-2")
opponentAvatar.name = "Ray"
}
Build and run your project and after about four seconds, you’ll see the opponent’s avatar appear:
Now that the app has found an opponent, it will start “connecting” the two players. All of this, of course, is just a simulation so you get to build a nice animation.
Add the following code to the bottom of foundOpponent():
delay(seconds: 4.0, completion: connectedToOpponent)
This inserts another four-second delay after the app displays the opponent’s avatar, after which you’ll call the “connected state” method in the next code block.
Add the following new method to the class:
func connectedToOpponent() {
myAvatar.shouldTransitionToFinishedState = true
opponentAvatar.shouldTransitionToFinishedState = true
}
The above method sets shouldTransitionToFinishedState to true on both avatar views. This triggers a new animation that you will create in the next challenge, so for now nothing will happen.
Finally, you need to adjust the UI for the final state. Add the following code to the bottom of connectedToOpponent():
delay(seconds: 1.0, completion: completed)
This code gives the animation a second to wrap up and then calls the final step in the sequence: completed().
Add the final method to the ViewController class:
func completed() {
status.text = "Ready to play"
UIView.animate(withDuration: 0.2) {
self.vs.alpha = 1.0
self.searchAgain.alpha = 1.0
}
}
completed() sets the status message at the top of the screen to “Ready to play”, then fades in the “vs.” label and the “Search Again” button to restart the animation sequence. The button is already connected to actionSearchAgain() so you can tap it to restart the animations.
Build and run to see the entire sequence of animations:
Challenge 2: Morph the avatars to squares
At this point the avatars just keep bouncing forever. Once the game is connected to an opponent, you’d like to stop the animation and reflect the state change in the UI.
In this challenge you are going to make use of the shouldTransitionToFinishedState property of the avatar class; when it’s set to true you’ll break the bounce animation and morph the avatars into a square shape.
Open AvatarView.swift and add a new variable called isSquare and set its initial value to false. Scroll to bounceOff(point: morphSize:) and find the animation completion block where there’s a recursive call to bounceOff(point: morphSize:).
You’ll need to wrap that call in a conditional so it only runs when isSquare is still false.
Next you’ll run an extra animation while the avatars bounce off each other one last time.
Find the //complete bounce to comment and replace it with the following:
if self.shouldTransitionToFinishedState {
self.animateToSquare()
}
Finally, add the animateToSquare() method to AvatarView and write the code to do the following:
-
Set
isSquaretotrue. -
Create a Bezier path with
UIBezierPath(rect:)by using the avatar’s bounds rectangle, and store theCGPathof this bezier path in a constant calledsquarePath. -
Create a new layer animation with a
keypathof path and set itsdurationto0.25seconds. -
Set the
fromValueof the animation to thecircleLayer.pathand thetoValuetosquarePath. This defines a morph animation from a circle to a square shape. -
Add the animation to the
circleLayerand then set its path property to thesquarePath. -
Similarly, add the animation to the
masklayer as well and set itspathproperty tosquarePath.
Build and run your project; the final bounce will look much fancier. While the avatars touch for the last time they’ll still look like this:
They bounce back to their starting point, morphing into squares for a wow effect:
By now you know the basic animation techniques to work with shape layers and masks. You’ve likely already thought up many ways to apply shape animations in your own applications.
You continue to work with shapes and CAShapeLayer animations in Chapter 15, “Stroke and Path Animations.” Head on to Chapter 14, “Gradient Animations,” to learn how to add some really neat effects to your animations using animated gradients.