Intermediate iOS 15 Programming with Swift

Chapter 23
View Controller Transitions and Animations

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.

Demo Project

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.

Figure 23.1. The demo app
Figure 23.1. The demo app

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.

Applying the Standard Transition

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.

Figure 23.2. Connecting the collection view cell with the detail view controller
Figure 23.2. Connecting the collection view cell with the detail view controller

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.

Creating a Slide Down Animator

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.

Figure 23.3. How the slidedown animation works
Figure 23.3. How the slidedown animation works

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.

Figure 23.4. Tapping the slide down icon will transit to the detail view with a slide-down animation
Figure 23.4. Tapping the slide down icon will transit to the detail view with a slide-down animation

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.

Reversing the Transition

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.

Figure 23.5. Forward and reverse transitions
Figure 23.5. Forward and reverse transitions

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.

Figuer 23.6. Reverse transition now works as expected
Figuer 23.6. Reverse transition now works as expected

Creating the Slide Right Transition Animator

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:

Figure 23.7. How the slide right transition works
Figure 23.7. How the slide right transition works

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.

Creating a Pop Transition Animator

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.

Figure 23.8. How the pop transition works
Figure 23.8. How the pop transition works

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.

Creating a Rotation Transition Animator

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.

Figure 23.9. How the rotation transition works
Figure 23.9. How the rotation transition works

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.

Figure 23.10. The unit coordinate space
Figure 23.10. 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:

Figure 23.11. Understanding anchor point
Figure 23.11. Understanding anchor point

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.