27.
Intermediate 3D Animations
Written by Marin Todorov
In the previous chapter, you learned that applying perspective to a single view isn’t a complicated task; in fact, once you know the secret of m34 and camera distance you can create all kinds of 3D animations.
This chapter builds on what you’ve already learned and shows you how to create convincing 3D animations with more than one view.
The starter project for this chapter is a simple hurricane image gallery. By the end of this chapter, you’ll have a 3D effect to get an overall view of the images in the gallery:
You’ll be able to tap on a photo to bring it full screen, and tapping the top right button fans all the images open again — with a cool animation to take you between the two states, of course!
Ready to get started? Hang on to your hat and prepare to get blown away by this “stormy” project!
Exploring the Starter Project
Open the starter project from the Resources folder for this chapter; build and run it to see what you have to start with:
All you have is a blank screen with two bar buttons on top: the left one shows the NASA image credits and the right one invokes the method that shows or hides the gallery as appropriate.
First, you’ll need to display all images on the screen and set them up so they’re ready for your “fan” animation.
Open ViewController.swift and inspect the class code. You’ll see an array called images; this array contains some slightly customized image views. The ImageViewCard class inherits from UIImageView and adds a string property title to hold the hurricane title, and a property called didSelect so you can easily set a tap handler on the image.
Your first task is to add all images to the view controller’s view. Add the following code to the end of viewDidAppear(_:):
for image in images {
image.layer.anchorPoint.y = 0.0
image.frame = view.bounds
view.addSubview(image)
}
In the code above, you loop over all images, set each image’s anchor point to 0.0 on the y-axis and resize each image so it takes up the full screen. When that’s done, you add each image to view.
Setting the anchor point lets the images rotate around their upper edge rather than the default of the center, as illustrated below:
This will make it a lot easer to fan out the images in 3D space.
Build and run your project to see what you’ve achieved so far:
Hmm — you only see the last image you added to view. That’s because all images have the same frame, so you only see the last image you added: Hurricane Irene.
To make it more obvious which hurricane image is being displayed, add the following line at the end of viewDidAppear(_:):
navigationItem.title = images.last?.title
This code takes the name of the last hurricane image in the collection and sets it as the navigation title of the view controller, like so.
Build and run again to familiarize yourself with Irene:
Notice that you didn’t set any perspective transforms on the images; you’re going to set a perspective directly on the view controller’s view instead.
In the previous chapter you adjusted the transform property on a single view and then rotated it in 3D space. But since your current project has more individual views that you’d care to manipulate in 3D, you can set the perspective of their parent view instead to save yourself a bunch of work.
Add the following code to viewDidAppear(_:):
var perspective = CATransform3DIdentity
perspective.m34 = -1.0 / 250.0
view.layer.sublayerTransform = perspective
Here you use the layer property sublayerTransform to set the perspective of all sublayers of the view controller’s layer. The sublayer transform is then combined with each individual layer’s own transform.
This lets you focus on managing the rotation or translation of your subviews without having to worry about perspective. You’ll see how this works in more detail in the next section.
Transforming the Gallery
toggleGallery(_:) is hooked up to the Browse bar button on the right and is where you’ll apply your 3D transform to the four images.
Add the following variable to toggleGallery(_:):
var imageYOffset: CGFloat = 50.0
Since you don’t just rotate all images in place but simply move them around to produce the “fan” animation, you use imageYOffset to set the offset of each image.
Next you need to iterate through all the images and run their individual animations.
Add the following code to toggleGallery(_:):
for subview in view.subviews {
guard let image = subview as? ImageViewCard else {
continue
}
// more code here
}
Here, you loop through all subviews of the view controller’s view and act only on the subviews that are instances of ImageViewCard.
Add the following code after the guard block you added above, to replace the more code here comment:
var imageTransform = CATransform3DIdentity
// 1
imageTransform = CATransform3DTranslate(
imageTransform, 0.0, imageYOffset, 0.0)
// 2
imageTransform = CATransform3DScale(
imageTransform, 0.95, 0.6, 1.0)
// 3
imageTransform = CATransform3DRotate(
imageTransform, .pi / 8, -1.0, 0.0, 0.0)
You start by assigning the identity transform to imageTransform and then add a series of adjustments to it. This is what each individual adjustment does to the image:
- Move the image on the y-axis with CATransform3DTranslate; this offsets the image from its default 0.0 y-coordinate as shown below:
Later, you’ll calculate the imageYOffset of each image separately; for now all images move by the same amount so you’ll still see only the top one for the moment.
- Scale the image by adjusting the scale component of the transform using
CATransform3DScale. You shrink the image just a little on the x-axis, but you scale it down to60%on the y-axis to enrich the rotation 3D effect:
- Finally, you use
CATransform3DRotateto rotate the image by22.5degrees to give it some perspective distortion as shown below:
Remember, you already set the anchor point so the image rotates around its top edge.
Now you see the value of setting the m34 value above via view.layer.sublayerTransform; your rotation transform simply re-uses the m34 value from the sublayer transform, without the need to apply it here. That’s handy!
Now all that’s left is to apply the transform to each image. Add the following line (still inside the for body):
image.layer.transform = imageTransform
Build and run your project; tap the Browse button to see the result of your transforms:
Again, you only see the top image as all other images behind it have the same transform applied. Now is a perfect opportunity to customize the transform for each image.
Add the following line to the end of the for block:
imageYOffset += view.frame.height / CGFloat(images.count)
This adjusts the y-offset of each image depending on where it is in the stack. You divide the screen height over the number of images so they distribute themselves evenly over the screen.
Build and run your project to see your views fan out:
Sweet — the gallery looks just as you intended! But as this is a book about animations, it would be a shame if you didn’t animate the transition to the fanned-out view, wouldn’t it?
Animating the Gallery
Find the following line in toggleGallery(_:) where you set transform on each image:
image.layer.transform = imageTransform
Insert the following code above that line to animate transform:
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = NSValue(caTransform3D:
image.layer.transform)
animation.toValue = NSValue(caTransform3D: imageTransform)
animation.duration = 0.33
image.layer.add(animation, forKey: nil)
This code is definitely familiar: You create a layer animation on the transform property and animate it from its current value to the imageTransform you designed earlier.
Build and run your project once more; tap the Browse button and enjoy your completed animation:
You’re finished with the gallery for now; you’ll revisit it in the Challenges section when you add the ability to close the fan when the user taps the Browse button.
Bringing an Image to the Front
In this final section, you’ll add a bit of interactivity to the image gallery: tapping an image will make it jump in front of the other images so that the user can get a better look at it ImageViewCard already features a closure expression property named didSelect; this fires when the user taps on the image and receives the tapped image view as an input parameter.
To add this feature, you’ll add a method to ViewController and assign it to didSelect on all the image views.
First, add the following code to viewDidAppear(), inside the for loop body:
image.didSelect = selectImage
Xcode will complain that selectImage doesn’t exist, but you’ll fix this in the next step.
Add the following method to ViewController:
func selectImage(selectedImage: ImageViewCard) {
for subview in view.subviews {
guard let image = subview as? ImageViewCard else {
continue
}
if image === selectedImage {
//selected image
} else {
//any other image
}
}
}
This is the skeleton of selectImage(selectedImage:); when the user taps one of the images, you loop over all subviews and get the ImageViewCard instances just like you did earlier. Then you check each ImageViewCard to see if it’s the selected image.
Now you need two more animations: one to animate the selected image, and another to animate all the other images in the gallery.
You’ll tackle this in reverse and fade out the unselected images first.
Replace the //any other image comment in selectImage(selectedImage:) with the following code:
UIView.animate(
withDuration: 0.33,
delay: 0.0,
options: .curveEaseIn,
animations: {
image.alpha = 0.0
},
completion: { _ in
image.alpha = 1.0
image.layer.transform = CATransform3DIdentity
})
This is a simple animation to fade out each image. In the completion block, you reset its transform to the identity transform and alpha to fully opaque. By the time the above animation is done, the selected image will be in front of all others — so transparent or not, you won’t see any of the unselected images. Resetting alpha here saves you from having to remember to reset it when you want to see the image again.
Build and run your project; open the gallery and tap on an image to see all the other unselected images fade from view.
When that animation completes, the view changes back to show the top image at full-screen — because you haven’t done anything with the selected image! You’ll fix that now.
You’ll animate the selected image’s transform back to the identity transform and pull the image to the front when the animation completes.
Replace the //selected image comment in selectImage(selectedImage:) with the following code:
UIView.animate(
withDuration: 0.33,
delay: 0.0,
options: .curveEaseIn,
animations: {
image.layer.transform = CATransform3DIdentity
},
completion: { _ in
self.view.bringSubviewToFront(image)
})
Here, you’re un-doing the 3D transform for the animation, and then ensuring the image is at the top of the view stack at the end so it’s visible.
Finally, add the following code to the end of selectImage(selectedImage:):
self.navigationItem.title = selectedImage.title
This will update the navigation bar with the currently selected image title. Build and run your project; tap the various images to see how they zoom to full screen. How does it look? I thought you’d enjoy this classy little animation! That’s a wrap; there’s one small bit of functionality to clean up in the Challenge section below, but other than that, you have a really stunning animation at your fingertips. I suspect you can think of many ways to use it in your own projects!
Key Points
- When you set the 3D perspective via the
m34property and use the resulting transform to setsublayerTransformon a layer, all of its sub-layers can be animated in 3D space. - You can combine
CATransform3Dlayer animations with UIKit view animations and let Core Animation automatically combine and render them on screen.
Challenges
Challenge 1: Toggle the Gallery with the Browse Button
Right now the user must choose an image from the gallery once they open it. In this challenge, you’ll make the Browse button work like a toggle to close the gallery view as well. Add a new property to ViewController named isGalleryOpen and set its initial value to false. You need to update the value of this property in couple of places in the code:
-
Set it to
trueat the end oftoggleGallery(_:) -
Set it to
falseat the end ofselectImage(selectedImage:)
In the for loop in toggleGallery(), add a check to see if the gallery is already open. If so, leave imageTransform as it is, instead of adding the translate, scale and rotation transforms to it. Don’t forget to toggle() isGalleryOpen at the end of the method.
That’s it — if you like, play around with the animations in this project and see what other flashy elements you can add on your own!