Wouldn't it be great if you could define the transition style between view controllers? Apple provides a handful of default animations for view controller transitions. Presenting a view controller modally usually uses a slide-up animation. The transition between two view controllers in navigation controller is predefined too. Pushing or popping a controller from the navigation controller's stack uses a standard slide animation. With the iOS SDK, developers are allowed to implement our own transitions through the View Controller Transitioning API. The API gives you full control over how one view controller presents another.
There are two types of view controller transitions: interactive and non-interactive. In iOS, you can pan from the leftmost edge of the screen and drag the current view to the right to pop a view controller from the navigation controller's stack. This is a great example of interactive transition. In this chapter, we are going to focus on the non-interactive transition first, as it is easier to understand.
The concept of custom transition is pretty simple. You create an animator object (or so-called transition manager), which implements the required custom transition. This animator object is called by the UIKit framework when one view controller starts to present or transit to another. It then performs the animations and informs the framework when the transition completes.
When implementing non-interactive view controller transitions, you basically deal with the following protocols:
UIViewControllerAnimatedTransitioning
- your animator object must adopt this protocol to create the animations for transitioning a view controller on or off screen.UIViewControllerTransitioningDelegate
- you adopt this protocol to vend the animator objects used to present and dismiss a view controller. Interestingly, you can provide different animator objects to manage the transition between two view controllers.UIViewControllerContextTransitioning
- this protocol provides methods for accessing contextual information for transition animations between view controllers. You do not need to adopt this protocol in your own class. Your animator object will receive the context object, provided by the system, during the transition.It looks a bit complicated, right? Actually, it's not. Once you go through a simple project, you will have a better idea about how to build custom transitions between view controllers.
We are going to build a simple demo app. To keep your focus on building the animations, download the project template from http://www.appcoda.com/resources/swift55/NavTransitionStarter.zip. The template comes with prebuilt storyboard and view controller classes. The user interface was built using collection view. If you do not have any experience with UICollectionView, read over chapter 18 and 20. If you have a trial run, you will end up with a screen similar to the one shown below.
Each icon indicates a unique custom transition. For now, the icons are not functional. Coming up next, you will learn how to implement all the transitions using the View Controller Transitioning API.
First, open the Main
storyboard. You should find the transitions view controller (embedded in a navigation controller) and the detail view controller showing product information. These two controllers are not connected yet. Control-drag from the collection view cell of the transition view controller to the detail view controller. Select Present modally
as the selection segue. Instead of using the default presentation mode, select the segue and change the presentation option in the Attributes inspector. Set its value to Full Screen
.
If you run the demo app again, tapping any of the items will bring up the detail view controller using the standard slide-up animation. What we are going to do is implement our own animator object to replace that animation.
In the project navigator, right-click the View folder to create a new Swift file. Name the class SlideDownTransitionAnimator
.
As mentioned earlier, the animator object should adopt both the UIViewControllerAnimatedTransitioning
and the UIViewControllerTransitioningDelegate
protocols. So update the file content like this:
import UIKit
class SlideDownTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {
}
Let's first talk about the UIViewControllerTransitioningDelegate
protocol. You adopt this protocol to vend the animator objects that manage the transition between view controllers. You have to implement the following methods in the SlideDownTransitionAnimator
class:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
The animationController(forPresented:presenting:source:)
method returns the animator object to use when presenting the view controller. Here we return self
, as the SlideDownTransitionAnimator
object is the animator.
The animationController(forDismissed:)
method, on the other hand, indicates the animator object to use when dismissing the view controller. In the above code, we simply return the current animator object.
Okay, let's move onto the implementation of UIViewControllerAnimatedTransitioning
protocol, which provides the actual animation for the transition. When adopting the protocol, you have to provide the implementation of the following required methods:
transitionDuration(using:)
animateTransition(using:)
The first method is simple. You just return the duration (in seconds) of the transition animation. The second method is where the transition animations take place. When presenting or dismissing a view controller, UIKit calls the animateTransition(using:)
method to perform the animations.
Before we dive into the code, let me explain how our own version of slide-down animation works. Take a look at the illustration below.
When a user taps the Slide Down icon, the current view controller begins to slide down off the screen. The detail view controller will also slide down from the top of the screen. When the animation ends, the detail view controller completely replaces the current view controller.
Okay, how can we implement an animation like that in code? First, insert the following code snippet in the SlideDownTransitionAnimator
. I will go through with you line by line later.
let duration = 0.5
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get reference to our fromView, toView and the container view
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
return
}
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
return
}
// Set up the transform we'll use in the animation
let container = transitionContext.containerView
let offScreenUp = CGAffineTransform(translationX: 0, y: -container.frame.height)
let offScreenDown = CGAffineTransform(translationX: 0, y: container.frame.height)
// Make the toView off screen
toView.transform = offScreenUp
// Add both views to the container view
container.addSubview(fromView)
container.addSubview(toView)
// Perform the animation
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.8, options: [], animations: {
fromView.transform = offScreenDown
fromView.alpha = 0.5
toView.transform = CGAffineTransform.identity
toView.alpha = 1.0
}, completion: { finished in
transitionContext.completeTransition(true)
})
}
At the beginning, we set the transition duration to 0.5
seconds. The first method simply returns the duration.
Let's take a closer look at the animateTransition
method. During the transition, there are two view controllers involved: the current view controller and the detail view controller. When UIKit calls the animateTransition(using:)
method, it passes a context object (which adopts the UIViewControllerContextTransitioning
protocol) containing information about the transition. From the context object, we can retrieve the view controllers involved in the transition using the viewControllerForKey
method. The current view controller, which is the view controller that appears at the start of the transition, is referred to as the "from view controller". The detail view controller, which is going to replace the current view controller, is referred to as the "to view controller".
We then configure two transforms for moving the views. To implement the slide-down animation, toView
should be first moved off the screen. The offScreenUp
variable is used for this purpose. The offScreenDown
transform will later be used to move fromView
off the screen during the transition.
The context object also provides a container view that acts as the superview for the view involved in the transition. It is your responsibility to add both views to the container view using the addSubview
method.
Lastly, we use the animate
method of UIView
to perform a spring animation:
// Perform the animation
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.8, options: [], animations: {
fromView.transform = offScreenDown
fromView.alpha = 0.5
toView.transform = CGAffineTransform.identity
toView.alpha = 1.0
}, completion: { finished in
transitionContext.completeTransition(true)
})
In the animation block, we specify the changes of fromView
and toView
. By applying the offScreenDown
transform to fromView
to move the view off the screen, and restoring toView
to the original position & alpha value, this creates the slide-down animation.
Okay, we've created the animator object. The next step is to use this class to replace the standard transition. In the MenuViewController.swift
file, declare the following variable to hold the animator object:
let slideDownTransition = SlideDownTransitionAnimator()
Next, implement the prepare(for:sender:)
method:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
let toViewController = segue.destination
let sourceViewController = segue.source as! MenuViewController
if let selectedIndexPaths = sourceViewController.collectionView.indexPathsForSelectedItems {
switch selectedIndexPaths[0].row {
case 0: toViewController.transitioningDelegate = slideDownTransition
default: break
}
}
}
The app only performs the slide-down transition when the user taps the Slide Down icon, so we first verify whether the first cell is selected. When the cell is selected, we set our SlideDownTransitionAnimator
object as the transitioning delegate.
Now compile and run the app. Tap on the Slide Down icon, you should get a nice slide-down transition to the detail view. However, the reverse transition doesn't work properly when you tap on the close button.
The resulting view, after transition, is dimmed. Obviously, the alpha value is not restored to the original value. And we expect the main view controller slides from the bottom of the screen instead of from the top.
To reverse the transition, we just need to add some simple logic to the SlideDownTransitionAnimator
class to keep track of whether the app is presenting the view controller or dismissing it and we perform the animation accordingly.
First, declare the isPresenting
variable in the class:
var isPresenting = false
This variable keeps track of whether we're presenting the view controller or dismissing one. Update the animationController(forDismissed:)
and animationController(forPresented:presenting:source:)
methods like this:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = true
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = false
return self
}
We simply set isPresenting
to true
when the view controller is presented, and set it to false
when the controller is dismissed. Next, update the animateTransition
method as shown below:
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get reference to our fromView, toView and the container view
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
return
}
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
return
}
// Set up the transform we'll use in the animation
let container = transitionContext.containerView
let offScreenUp = CGAffineTransform(translationX: 0, y: -container.frame.height)
let offScreenDown = CGAffineTransform(translationX: 0, y: container.frame.height)
// Make the toView off screen
if isPresenting {
toView.transform = offScreenUp
}
// Add both views to the container view
container.addSubview(fromView)
container.addSubview(toView)
// Perform the animation
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.8, options: [], animations: {
if self.isPresenting {
fromView.transform = offScreenDown
fromView.alpha = 0.5
toView.transform = CGAffineTransform.identity
} else {
fromView.transform = offScreenUp
fromView.alpha = 1.0
toView.transform = CGAffineTransform.identity
toView.alpha = 1.0
}
}, completion: { finished in
transitionContext.completeTransition(true)
})
}
For the reverse transition, fromView
and toView
are reversed. In other words, the detail view is now fromView
, while the main view becomes toView
.
We only want to make toView
(i.e. detail view) off the screen in forward transition. So the offScreenUp
transform is applied when the isPresenting
variable is set to true
.
In the animation block, the code is unchanged when isPresenting
is set to true
. But for reverse transition, we perform a different animation. We move the detail view (i.e. fromView
) off the screen by applying the offScreenUp
transform. For toView
, it is restored to the original position and its alpha
value is reset to 1.0
.
Now run the app again. When you close the detail view, the animation should work like this.
If you understand the material I've covered so far, it is pretty straightforward for you to create the slide right transition animator. The slide right animation will work like this:
The detail view controller is first moved off the screen to the left. When the user taps on the Slide Right icon, the detail view slides into the screen to replace the main view. This time we keep the main view intact.
Okay, let's go into the implementation. In the project navigator, create a new Swift file named SlideRightTransitionAnimator
with the following content:
import UIKit
class SlideRightTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {
let duration = 0.5
var isPresenting = false
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get reference to our fromView, toView and the container view
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
return
}
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
return
}
// Set up the transform we'll use in the animation
let container = transitionContext.containerView
let offScreenLeft = CGAffineTransform(translationX: -container.frame.width, y: 0)
// Make the toView off screen
if isPresenting {
toView.transform = offScreenLeft
}
// Add both views to the container view
if isPresenting {
container.addSubview(fromView)
container.addSubview(toView)
} else {
container.addSubview(toView)
container.addSubview(fromView)
}
// Perform the animation
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.8, options: [], animations: {
if self.isPresenting {
toView.transform = CGAffineTransform.identity
} else {
fromView.transform = offScreenLeft
}
}, completion: { finished in
transitionContext.completeTransition(true)
})
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = true
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = false
return self
}
}
The code is very similar to the one we developed previously.
First, we move the detail view (i.e. toView
) off the screen to the left by applying the offScreenLeft
transform. If the isPresenting
variable is set to true
, toView
should be placed on top of fromView
. This is why we first add fromView
to the container view, followed by toView
. Conversely, for reverse transition, the detail view (i.e. fromView
) should be placed above the main view before the transition begins.
For the animation block, the code is simple. When presenting the detail view (i.e. toView
), we set its transform
property to CGAffineTransform.identity
in order to move the view to the original position. When dismissing the detail view (i.e. fromView
), we move it off screen again.
Before testing the animation, there is still one thing left. We need to hook up this animator to the transition delegate. Open the MenuViewController.swift
file and declare the slideRightTransition
variable:
let slideRightTransition = SlideRightTransitionAnimator()
Change the prepare(for:sender:)
method by updating the switch
statement like this:
switch selectedIndexPaths[0].row {
case 0: toViewController.transitioningDelegate = slideDownTransition
case 1: toViewController.transitioningDelegate = slideRightTransition
default: break
}
Now when you run the project, you should see a slide right transition when tapping the Slide Right icon.
Let's move onto the pop transition. The pop animation is illustrated as below. When the user taps the Pop icon, the detail view will pop up from the screen. At the same time, the main view will change to a smaller size.
Similar to the slide animation, to implement the pop animation, the detail view (i.e. toView
) is first minimized. Once a user taps the Pop icon, the detail view grows in size till it is restored to its original size.
Now create a new Swift file and name it PopTransitionAnimator
. Make sure you import the UIKit
framework and implement the class like this:
import UIKit
class PopTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {
let duration = 0.5
var isPresenting = false
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get reference to our fromView, toView and the container view
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
return
}
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
return
}
// Set up the transform we'll use in the animation
let container = transitionContext.containerView
let minimize = CGAffineTransform(scaleX: 0, y: 0)
let offScreenDown = CGAffineTransform(translationX: 0, y: container.frame.height)
let shiftDown = CGAffineTransform(translationX: 0, y: 15)
let scaleDown = shiftDown.scaledBy(x: 0.95, y: 0.95)
// Change the initial size of the toView
toView.transform = minimize
// Add both views to the container view
if isPresenting {
container.addSubview(fromView)
container.addSubview(toView)
} else {
container.addSubview(toView)
container.addSubview(fromView)
}
// Perform the animation
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.8, options: [], animations: {
if self.isPresenting {
fromView.transform = scaleDown
fromView.alpha = 0.5
toView.transform = CGAffineTransform.identity
} else {
fromView.transform = offScreenDown
toView.alpha = 1.0
toView.transform = CGAffineTransform.identity
}
}, completion: { finished in
transitionContext.completeTransition(true)
})
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = true
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = false
return self
}
}
Again I will not walk you through the code line by line because you should now have a better understanding of the view controller transition. The logic is very similar to that of the previous two examples. Here we just define a different set of transforms. For example, we use the following CGAffineTransform to minimize the detail view:
CGAffineTransform(scaleX: 0, y: 0)
In the animation block, when presenting the detail view, the main view (i.e. fromView
) is shifted down a little bit and reduced in size. In the case of dismissing the detail view, we simply move the detail view off the screen.
Now go to the MenuViewController.swift
file and declare the popTransitionAnimator
variable:
let popTransition = PopTransitionAnimator()
Update the switch
block like this to configure the transitioning delegate:
switch selectedIndexPaths[0].row {
case 0: toViewController.transitioningDelegate = slideDownTransition
case 1: toViewController.transitioningDelegate = slideRightTransition
case 2: toViewController.transitioningDelegate = popTransition
default: break
}
Now hit the Run button to test out the transition. When you tap the Pop icon, you will get a nice pop animation.
Now that we have created three custom transitions, we come to the last animation, which is a bit more complicated than the previous one. While I name the animation Rotation Transition, the effect actually works like the example below.
The detail view is initially turned sideways and moved off the screen. When the transition begins, the detail view swings back to the original position, while the main view rotates counterclockwise and swings off the screen.
Okay, let's first create a new Swift file called RotateTransitionAnimator
. Implement the class like this:
import UIKit
class RotateTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {
let duration = 0.5
var isPresenting = false
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get reference to our fromView, toView and the container view
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
return
}
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
return
}
// Set up the transform we'll use in the animation
let container = transitionContext.containerView
// Set up the transform for rotation
// The angle is in radian. To convert from degree to radian, use this formula
// radian = angle * pi / 180
let rotateOut = CGAffineTransform(rotationAngle: -90 * CGFloat.pi / 180)
// Change the anchor point and position
toView.layer.anchorPoint = CGPoint(x:0, y:0)
fromView.layer.anchorPoint = CGPoint(x:0, y:0)
toView.layer.position = CGPoint(x:0, y:0)
fromView.layer.position = CGPoint(x:0, y:0)
// Change the initial position of the toView
toView.transform = rotateOut
// Add both views to the container view
container.addSubview(toView)
container.addSubview(fromView)
// Perform the animation
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.8, options: [], animations: {
if self.isPresenting {
fromView.transform = rotateOut
fromView.alpha = 0
toView.transform = CGAffineTransform.identity
toView.alpha = 1.0
} else {
fromView.alpha = 0
fromView.transform = rotateOut
toView.alpha = 1.0
toView.transform = CGAffineTransform.identity
}
}, completion: { finished in
transitionContext.completeTransition(true)
})
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = true
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresenting = false
return self
}
}
Let's discuss the first code snippet. To build the animation, the first thing that comes to your mind is to create a rotation transform. You provide the angle of rotation in radian, for which a positive value indicates a clockwise direction while a negative value specifies a counter clockwise rotation. Here is an example:
let rotateOut = CGAffineTransform(rotationAngle: -90 * CGFloat.pi / 180)
If you apply the above transform to the detail view, you will rotate the view by 90 degrees counter clockwise. However, the rotation happens around the center of the screen. Obviously, to perform our expected animation, the detail view should be rotated around the top-left corner of the screen.
By default, the anchor point of a view's layer (CALayer
class) is set to the center. You specify the value for this property using the unit coordinate space.
To change the anchor point to the top left corner of the layer, we set it to (0, 0) for both fromView
and toView
.
toView.layer.anchorPoint = CGPoint(x:0, y:0)
fromView.layer.anchorPoint = CGPoint(x:0, y:0)
But why do we need to change the layer's position in addition to the anchor point? The layer's position is set to the center of the view. For instance, if you are using iPhone 5, the position of the layer is set to (160, 284). Without altering the position, you will end up with an animation like this:
Since the layer's anchor point was changed to (0, 0) and the position is unchanged, the layer moves so that its new anchor point is at the unchanged position. This is why we have to change the position of both fromView
and toView
to (0, 0).
For the animation block, we simply apply the rotation transform to fromView
and toView
accordingly. When presenting the detail view (i.e. toView
), we restore it to the original position and rotate the main view off the screen. We do the reverse when dismissing the detail view.
Go to the MenuViewController.swift
file and declare a variable for the RotateTransitionAnimator
object:
let rotateTransition = RotateTransitionAnimator()
Lastly, update the switch block to hook up the RotateTransitionAnimator
object:
switch selectedIndexPaths[0].row {
case 0: toViewController.transitioningDelegate = slideDownTransition
case 1: toViewController.transitioningDelegate = slideRightTransition
case 2: toViewController.transitioningDelegate = popTransition
case 3: toViewController.transitioningDelegate = rotateTransition
default: break
}
Now compile and run the project again. Tap the Rotate icon, and you will get an interesting transition.
In this chapter, I showed you the basics of custom view controller transitions. Now it is time to create your own animation in your apps. Good design is much more than visuals. Your app has to feel right. By implementing proper and engaging view controller transitions, you will take your app to the next level.
For reference, you can download the final project from http://www.appcoda.com/resources/swift55/NavTransition.zip.