20.
UINavigationController Custom Transition Animations
Written by Marin Todorov
Using a navigation stack via a UINavigationController is a common way to let your app’s users navigate through your app’s UI. Pushing a new view controller onto the navigation stack or popping one off gives you a sleek animation with no work on your part. A new screen comes from the right and pushes away the old one with a slight lag:
The above screenshot shows how iOS pushes a new view controller onto the navigation stack in the Settings app: The new view slides in from the right to cover the old view and the new title fades in while the old title titles fades from view.
The navigation paradigm in iOS has become old hat to users, as the same animations have been used for many years. This frees you to embellish your navigation controller transitions without throwing the user off.
In much the same way you built custom presenting view controllers in the previous chapter, you can build custom transitions to push and pop new view controllers.
You’ll be working with the Logo Reveal project. In this chapter, you’ll add a custom transparent view that gives the user a glimpse of the content hidden behind:
If you worked through the previous chapter, you’ll find that custom navigation controller transitions feel quite similar to presenting view controllers.
Introducing Logo Reveal
Open the starter project for this chapter and select Main.storyboard. You’ll see the project features a navigation controller, main view controller, and a detail view controller.
It will look like this:
The navigation’s already been hooked up for you so you can focus on customizing your navigation controllers.
Build and run your project; tap anywhere on the default screen (MainViewController) to present the vacation packing list (DetailViewController):
Custom Navigation Transitions
UIKit lets you customize navigation transitions via the delegate pattern in almost the same way you do for presenting view controllers.
You’ll make your MainViewController class adopt the UINavigationControllerDelegate protocol and set it to be the delegate to your navigation controller. Each time you push a view controller onto the navigation stack, the navigation controller will ask its delegate whether it should use the built-in transition or a custom one, as illustrated below:
When you push or pop a view controller, the navigation controller asks its delegate to provide an animation controller for that operation.
If you return nil from that delegate method, the navigation controller will use the default transition. However, if you return an object, the navigation controller will use this instead as a custom transition animation controller. Yup — this sounds a lot like the previous chapter, doesn’t it?
The animation controller should adopt the same UIViewControllerAnimatedTransitioning protocol you worked with in the previous chapter.
Once you provide an animation controller object (or animator), the navigation controller will call the following methods on it:
First, the navigation controller calls transitionDuration() to find out how long the transition will last; it then calls animateTransition(), which is where your custom transition animation code will live.
The Navigation Controller Delegate
Before you can implement the delegate methods, you’ll need to create the basic skeleton of the animator class.
From Xcode’s main menu select File\New\File… and choose the template iOS\Source\Cocoa Touch Class.
Set the new class name to RevealAnimator and make it a subclass of NSObject.
Make the new class comply with the UIViewControllerAnimatedTransitioning protocol like so:
class RevealAnimator: NSObject, UIViewControllerAnimatedTransitioning {
}
Now you need to implement the two required UIViewControllerAnimatedTransitioning methods to resolve Xcode’s error messages.
Add the following properties to the class:
let animationDuration = 2.0
var operation: UINavigationController.Operation = .push
Your animation will last two seconds. That’s a long time in the UI navigation world, but it will let you see your animation in minute, excruciating detail. operation is a property of type UINavigationController.Operation that tells whether you’re pushing or popping a view controller.
Now add the following two UIViewControllerAnimatedTransitioning methods to the class:
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
}
transitionDuration() returns the animation duration in seconds, while animateTransition() is the future home for your custom animations. You’ll populate animateTransition() once you finish setting up your navigation controller delegate.
All your Xcode errors should have resolved by this point. Open MainViewController.swift, which will serve as your navigation controller delegate.
Add the code below to the bottom of the file, outside of the class definition:
extension MainViewController: UINavigationControllerDelegate {
}
This adopts the UIViewNavigationControllerDelegate protocol in a new extension; this view controller can now serve as the navigation controller delegate.
You’ll need to set the navigation controller’s delegate early in the view controller lifecycle, before you invoke any segues or push something onto the stack.
Add the following code to viewDidLoad():
navigationController?.delegate = self
Your next task is to create an instance of RevealAnimator and pass it to the navigation controller when asked for an animation controller.
Add the following property to MainViewController:
let transition = RevealAnimator()
This is the animator you’ll use for pushing and popping view controllers.
Now that you have your animator, add the following method to the class extension:
func navigationController(_
navigationController: UINavigationController,
animationControllerFor
operation: UINavigationController.Operation,
from fromVC: UIViewController,
to toVC: UIViewController) ->
UIViewControllerAnimatedTransitioning? {
transition.operation = operation
return transition
}
That’s one monster of a method name, but if you cut through the noise you’ll see that it boils down to the following parameters:
-
navigationController: This helps to distinguish between navigation controllers in the event your object is a delegate of more than one navigation controller; this isn’t likely, but you still need to protect against the possibility. -
operation: This is aUINavigationController.Operationvalue, either.pushor.pop. -
fromVC: This is the view controller currently visible on the screen; it’s usually the last view controller in the navigation stack. -
toVC: This is the view controller you’ll transition to.
If you support different transitions for different view controllers, this is where you’ll choose what kind of animator object to return. To keep things simple in this project, you’ll always return your RevealAnimator object after you set the animator’s operation property to indicate either a push or pop transition.
Build and run your project; tap the first view controller and you’ll see the navigation bar animate over two seconds.
Note that the update to the navigation bar lasts for the duration you specified in RevealAnimator – but at this point nothing else will happen. Your animator takes control of the transition, but since you didn’t write any code in animateTransition(), no animation of the content takes place.
However, at least this indicates that the navigation controller is calling through to your custom transition properly. Now it’s time to get animating!
Adding a Custom Reveal Animation
The plan for your custom transition animation is relatively simple. You’ll simply animate a mask on DetailViewController to make it look like the transparent part of the RW logo reveals the contents of the underlying view controller. You’ll have to juggle layers and some animation tasks, but it’s nothing you haven’t done so far in the book. Creating the transition animation will be an easy feat for an animation pro like you!
Open RevealAnimator.swift and add the following property:
weak var storedContext: UIViewControllerContextTransitioning?
Since you’re going to create some layer animations for your transition, you’ll need to store the animation context somewhere until the animation ends and the delegate method animationDidStop(_:finished:) executes. At that point, you’ll call completeTransition() from within animationDidStop() to wrap up the transition.
Add the following code to animateTransition() to store the transition context for later use:
storedContext = transitionContext
Note: If you skipped ahead, you can learn more detail on how you fetch transition view controllers from the context and animation container views in Chapter 19, “Presentation Controller & Orientation Animations”.
Now add the following initial transition code to animateTransition():
let fromVC = transitionContext.viewController(forKey:
.from) as! MainViewController
let toVC = transitionContext.viewController(forKey:
.to) as! DetailViewController
transitionContext.containerView.addSubview(toVC.view)
toVC.view.frame = transitionContext.finalFrame(for: toVC)
Since you’ll work on the push transition initially, you can make an assumption about the identity of the “from” and “to” view controllers of the transition.
First, you fetch the “from” view controller (fromVC) and cast it to a MainViewController; you then fetch toVC as a DetailViewController.
Finally, you simply add toVC.view to the transition container view and set its frame to the “final” frame within the transitionContext. This places the vacation packing list in its final location over the main screen.
Now you’re going to create the reveal animation. The secret to a reveal animation is to have an object — in your case, the RW logo — grow to cover the entire area of the screen.
This sounds like a job for a scale transformation! Add the following to animateTransition():
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue =
NSValue(caTransform3D: CATransform3DIdentity)
animation.toValue =
NSValue(caTransform3D:
CATransform3DConcat(
CATransform3DMakeTranslation(0.0, -10.0, 0.0),
CATransform3DMakeScale(150.0, 150.0, 1.0)
)
)
This animation grows the logo 150 times in size and moves it up a little at the same time. Why? The logo is uneven in shape and you want the view controller behind to show through the “hole” of the RW shape.
Moving it up a little bit means the bottom of the zoomed image will cover the screen much faster.
The image below shows how your zoom animation will work:
If you used a symmetrical shape like a circle or ellipse, you wouldn’t have this problem, but your animation wouldn’t be nearly as cool.
Now add the following lines to animateTransition() to refine the animation a bit:
animation.duration = animationDuration
animation.delegate = self
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
animation.timingFunction = CAMediaTimingFunction(name:
.easeIn)
First, you set the duration of the animation to match the transition duration. You then set the animator as the delegate and configure the animation model to leave the animation on screen; this avoids glitches when the transition wraps up since the RW logo will be hidden away anyway. Finally, you add easing to make the reveal effect accelerate over time.
RevealAnimator is not currently an animation delegate, so jump to the top of the file and add the CAAnimationDelegate protocol to the class definition like so:
class RevealAnimator: NSObject, UIViewControllerAnimatedTransitioning, CAAnimationDelegate {
...
}
This should clear the compiler error you currently have.
Your animation is complete — but to which layer should it be applied?
Add the following code to the end of animateTransition(using:):
let maskLayer: CAShapeLayer = RWLogoLayer.logoLayer()
maskLayer.position = fromVC.logo.position
toVC.view.layer.mask = maskLayer
maskLayer.add(animation, forKey: nil)
This creates a CAShapeLayer to be applied to the DestinationViewController. The maskLayer is positioned in the same location as the “RW” logo on the MainViewController. Then you simply set maskLayer as the mask of the view controller’s view.
You then add the animation to the mask layer — which means you can test out the current state of your transition.
Build and run your project to see how things look so far:
Not bad, not bad; your reveal is running, but the animation is somewhat clunky and you can’t go back to the main screen once you push the pack list on top. Time to fix those issues!
Taking Care of the Rough Edges
You likely noticed you can still see the original logo behind the zooming reveal logo. The easiest way to handle this is to run the reveal animation on the original logo as well. You already have the animation, so it’s no matter to reuse it. This will make the original logo grow with the mask, matching its shape exactly so it won’t be in the way.
Add the following to animateTransition():
fromVC.logo.add(animation, forKey: nil)
Build and run your project again to verify that the original logo is no longer hanging around.
Now, a slightly harder problem: What’s up with the navigation not working any more after the first push transition?
If you take a look at what you’ve done so far, you’ll see that you never really wrap up the transition. You planned to call completeTransition() when the animation ended — but never got around to implementing that code.
RevealAnimator is set as the delegate of your reveal animation. Therefore, you need to override animationDidStop(_:finished:) and complete the transition within that method.
Add the following code to RevealAnimator:
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if let context = storedContext {
context.completeTransition(!context.transitionWasCancelled)
// reset logo
}
storedContext = nil
}
Here you check whether you have a stored transition context; if so, you call completeTransition() on it. This passes the ball back to the navigation controller to wrap up with the transition on UIKit’s side.
At the end of the method, you simply set the reference to the transition context to nil.
Since the reveal animation won’t be removed automagically upon completion, you’ll need to handle things yourself.
Replace the // reset logo comment, located in animationDidStop(), with the following:
let fromVC = context.viewController(forKey: .from)
as? MainViewController
fromVC?.logo.removeAllAnimations()
Since the only time you need to mask the contents of the view controller is during push transitions, you can safely remove the mask once the view controller finishes transitioning.
Add the following code below removeAllAnimations() in animationDidStop():
let toVC = context.viewController(forKey: .to)
as? DetailViewController
toVC?.view.layer.mask = nil
This will remove the mask after the view has appeared and the transition is complete.
That should do it. Build and run your project; push the packing list view controller onto the screen, then tap Start to return to the original view. The proof that you’re calling your custom transition is shown by the following crash:
You’re trying to cast the “from” view controller to a MainViewController instance – which is true only for the push transition, but not for the pop transition. Whoops.
Open RevealAnimator.swift and find animateTransition(). You’ll need to wrap most of the code here in a conditional. Replace the two lines where you assign toVC and fromVC with the following:
if
operation == .push,
let fromVC = transitionContext
.viewController(forKey: .from) as? MainViewController,
let toVC = transitionContext
.viewController(forKey: .to) as? DetailViewController {
Then, scroll all the way to the end of the method and add a closing brace for the if at the very end of the method.
The condition checks whether you’re dealing with a push transition before you try to cast it, and that the view controllers are what you expect them to be. This should take care of that crashing piece of code.
Build and run to try it out again. Your push transition is working, but your pop transition won’t do anything yet since you don’t have any code in animateTransition() to handle it.
This is where you get to flex your ninja coding muscles; you’ll create the pop transition on your own in the Challenges section below – and add a bit of elegance to the reveal animation along the way.
Key Points
- To enable and customize custom navigation transitions, you adopt the
UINavigationControllerDelegateprotocol in one of your types and make it your navigation delegate. - To perform any custom navigation transition animations, you adopt the
UIViewControllerAnimatedTransitioningprotocol your transition’s animator. - As long as you wrap up your transition correctly and call into the neccessary UIKit methods at the end, you can successfully use layer animations for your custom transitions.
- To preserve the context of the navigation transition and access it asynchronously throughout the transition’s duration, you can retain it in a
UIViewControllerContextTransitioningproperty within your animator type.
Challenges
Challenge 1: Fade in the New View Controller
Right now the transition looks like a sharp cutout; the contents of the new view controller are visible instantly and make the whole animation look a bit clunky.
Your challenge is to fade in the new view controller as the reveal animation runs.
To do this, create a fade in CABasicAnimation and add it to the layer of toVC.view. Use the same transition duration for this new animation and set fromValue and toValue to animate from fully transparent to fully opaque.
Call your new animation just after the point where you add the reveal animation to both the logo and the mask layers.
When you are finished, your transition should look like the following:
It appears as if your cutout becomes progressively more transparent as it grows; this gives the transition a mysterious effect.
Challenge 2: Add Pop Transition
To create a pop transition, you’ll simply add a complementary else branch to the if statement inside animateTransition() of RevealAnimator. Inside the else branch you can add any animations you want, but don’t forget to call completeTransition() when you’re finished. Here’s how to create a simple shrink transition:
-
Add an else branch to the if inside
animateTransition(). -
Fetch the “from” and “to” views using the
viewForKeymethod on the context. Because you’re not doing anything with the properties of the view controller this time, you can just fetch the views alone. -
Work with the transition
containerViewand insert the toView below fromView. Tip: useinsertSubview(_:belowSubview:_). -
Use an animation to scale
fromViewto0.01. Don’t use0.0for scaling — this will confuse UIKit. For this animation you can use an ordinary view animation – there’s no need to create a layer animation. -
When the animation finishes, call
completeTransition()on the transition context just as you did before.
This will result in the following tasteful shrinking transition:
As you might have guessed, you can create custom transitions for UITabBarController too. You won’t cover them here, but they work in a similar way to navigation controller transitions so you can easily figure them out based on what you’ve learned so far.
The next chapter takes transitions to the next level, and shows you how to let your user interact with the transitions themselves!