21.
Interactive UINavigationController Transitions
Written by Marin Todorov
The reveal transition you created in the previous chapter looks pretty neat, but custom animations are only half the story. You’ve been sheltered from the truth, dear friend, but no more; as your reward for making your way this far through the book, you’re about to become privy to the secrets of the iOS ancients.
Not only can you create a custom animation for your transition — you can also make it interactive and respond to the actions of the user.
Typically, you’d drive this action through a pan gesture, which is the approach you’re going to take in this chapter.
When you’re done, your users will be able to scrub back and forth through the reveal transition by sliding their finger across the screen. How cool would that be?
Yeah, I thought you’d be interested! Read on to see how it’s done!
Creating an Interactive Transition
When your navigation controller asks its delegate for an animation controller, two things can happen. You can return nil, in which case the navigation controller runs the standard transition animation. You know that much already. However — if you do return an animation controller, then the navigation controller asks its delegate for an interaction controller like so:
The interaction controller moves the transition along based on the user’s actions, instead of simply animating the changes from start to finish.
The interaction controller does not necessarily need to be a separate class from the animation controller; in fact, performing some tasks is a little easier when both controllers are in the same class. You just need to make sure that said class conforms to both UIViewControllerAnimatedTransitioning and UIViewControllerInteractiveTransitioning.
UIViewControllerInteractiveTransitioning has only one required method — startInteractiveTransition(_:) — that takes a transitioning context as its parameter. The interaction controller then regularly calls updateInteractiveTransition(_:) to move the transition along. To begin, you’ll need to change how you handle your user input.
Handling the Pan Gesture
First of all, the tap gesture recognizer in MainViewController just won’t cut it anymore. A tap happens momentarily and then it’s gone; you can’t track its progress and use it to drive a transition. On the other hand, a pan gesture has clear states for the starting, progressing, and ending phases of the transition.
Open the starter project for this chapter; alternatively, you can use your completed project (including the challenges) from the previous chapter.
Open Main.storyboard; go to the main view controller and change the text of the label towards the bottom of the screen to “Slide to start” like so:
This will reflect the action you expect from the user.
Next, open MainViewController.swift and remove the following code from viewDidAppear(_:):
let tap = UITapGestureRecognizer(target: self, action: #selector(didTap))
view.addGestureRecognizer(tap)
In its place, insert the following pan recognizer code:
let pan = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:)))
view.addGestureRecognizer(pan)
As the user pans across the screen, the recognizer invokes didPan(_:) on your MainViewController class.
To get rid of the error, showing right now in Xcode, add an empty didPan(_:) method to MainViewController:
@objc func didPan(_ recognizer: UIPanGestureRecognizer) {
}
You’ll need to modify your RevealAnimator class quite a bit to handle the new, interactive transition; you’ll take care of this in the next section.
Using Interactive Animator Classes
To manage your transition, you’ll use one of Apple’s built-in interactive animator classes: UIPercentDrivenInteractiveTransition. This class conforms to UIViewControllerInteractiveTransitioning and lets you get and set your transition’s progress as a value representing the percentage complete.
This makes your life a little easier, as you can use this class to adjust the percentComplete property accordingly and call update() to set the current visible progress of the transition. This will skip through the transition animation to the point that corresponds to the calculated transition progress. You’ll learn more about how UIPercentDrivenInteractiveTransition works as you work through the rest of this chapter.
Open RevealAnimator.swift and update the class definition at the top of the file as follows:
class RevealAnimator: UIPercentDrivenInteractiveTransition,
UIViewControllerAnimatedTransitioning, CAAnimationDelegate {
Note that UIPercentDrivenInteractiveTransition is a class and not a protocol like the rest so it needs to be in first position. Now RevealAnimator inherits from UIPercentDrivenInteractiveTransition.
Next, add the following property to tell the animator whether or not it should drive the transition in an interactive fashion:
var interactive = false
Now add the following method to RevealAnimator:
func handlePan(_ recognizer: UIPanGestureRecognizer) {
}
When the user pans across the screen, you’ll pass the recognizer to handlePan(_:) in RevealAnimator, at which point you’ll update the current progress of the transition. You’ll populate handlePan(_:) in just a bit, but first you’ll need to set up the gesture handling.
Open MainViewController.swift and add the following delegate method to provide an interaction controller to the UINavigationControllerDelegate extension in that file:
func navigationController(
_ navigationController: UINavigationController,
interactionControllerFor
animationController: UIViewControllerAnimatedTransitioning
) -> UIViewControllerInteractiveTransitioning? {
if !transition.interactive {
return nil
}
return transition
}
You only return an interaction controller when you want the transition to be interactive. For example, in your Logo Reveal project the reveal transition is interactive, but the custom pop transition will remain as-is.
Now you need to hook up your pan gesture recognizer to the interaction controller. Find didPan(_:) in MainViewController and replace with:
@objc func didPan(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
transition.interactive = true
performSegue(withIdentifier: "details", sender: nil)
default:
transition.handlePan(recognizer)
}
}
As the pan gesture starts, you ensure interactive is set to true and then begin the segue to the next view controller. Performing the segue kicks off the transition as detailed in the previous chapter; the delegate methods you’ve added return transition for the animation controller and for the interaction controller.
In all cases, if the gesture has already started you simply hand things over to the interaction controller as illustrated below:
Calculating Your Animation’s Progress
The most important bit of your pan gesture handler is to figure out how far along the transition should be.
Open RevealAnimator.swift and add the following code to handlePan():
let translation = recognizer.translation(
in: recognizer.view?.superview)
var progress: CGFloat = abs(translation.x / 200.0)
progress = min(max(progress, 0.01), 0.99)
First, you get the translation from the pan gesture recognizer; the translation lets you know how many points the user moved their finger/stylus/appendage/whatever on both the X and Y axes. Logically, the further the user pans from the initial location, the greater the progress of the transition.
To calculate the current progress, you take the translation on the X axis and divide it by 200 points. For example, if the user’s finger is 100 points away from the initial pan location, the transition will be 50% complete. 200 points is a bit of an arbitrary number, but it’s a good starting point for the total distance the user needs to pan to complete the transition. You shouldn’t care whether the user pans to the right or to the left - that’s why you use abs() to get the absolute value of the pan distance.
Finally, you cap the progress variable between 0.01 and 0.99; my testing shows that interaction controllers behave better if you don’t let the user finish or revert the transition from the pan gesture alone.
Now that you know the progress of the transition animation, you can update the transition animation as well.
Add the following code to handlePan():
switch recognizer.state {
case .changed:
update(progress)
default:
break
}
update() is a method from UIPercentDrivenInteractiveTransition which sets the current progress of the transition animation.
As the user pans across the screen, the gesture recognizer repeatedly calls didPan() in MainViewController, which in turn forwards the recognizer to handlePan() in RevealAnimator.
Unfortunately, if you were to build and run at this time, you’d see some of the animations appear to follow your gesture and the others just run along at their own pace. UIPercentDrivenInteractiveTransition doesn’t play as nicely with layer animations as it does with view animations, so you have to do some extra work.
First, add this property and calculated variable to RevealAnimator:
private var pausedTime: CFTimeInterval = 0
private var isLayerBased: Bool {
return operation == .push
}
Only the push transition uses layers, so you only need to do the following work when animating the push. Now, take control of the layer by adding the following code to the beginning of animateTransition(using:):
if interactive && isLayerBased {
let transitionLayer = transitionContext.containerView.layer
pausedTime = transitionLayer.convertTime(CACurrentMediaTime(), from: nil)
transitionLayer.speed = 0
transitionLayer.timeOffset = pausedTime
}
What you’re doing here is stopping the layer from running its own animations. This will freeze all sublayer animations as well. Now override update(_:) to move the layer along with the animation:
override func update(_ percentComplete: CGFloat) {
super.update(percentComplete)
if isLayerBased {
let animationProgress = TimeInterval(animationDuration) * TimeInterval(percentComplete)
storedContext?.containerView.layer.timeOffset =
pausedTime + animationProgress
}
}
Here, you’re calculating how far through the animation you are and setting the layer’s timeOffset accordingly, which moves the animations along to the appropriate point in the timeline.
Build and run your project; pan across the screen to see what your transition looks like:
Since your transition isn’t quite complete, the whole navigation breaks as soon as you lift your finger. However, you can see that the reveal animation followed your pan gesture – you’re getting close to completing your interactive transition!
Note: It feels like this extra work for layer animations is a UIKit bug; if you don’t use layer animations in the transition, you don’t have to do all this messing about, but under the hood the transition is doing the same sort of thing to make the view animations scrub back and forth.
All that’s left is to handle the end state of your interactive transition.
Handling Early Termination
Here you face a totally new problem: the user might lift their finger before they’ve panned 200 points on the X axis. This leaves the transition in an unfinished state.
Luckily, UIPercentDrivenInteractiveTransition gives you a couple of methods for free that you can use to revert, or complete, the transition depending on the user’s actions.
Add the following two cases inside the switch statement you added above, just before the default case:
case .cancelled, .ended:
if progress < 0.5 {
cancel()
} else {
finish()
}
The .cancelled and .ended cases are effectively the same thing as far as your project is concerned. In either case, if the user panned far enough before they released, you fully present the new view controller; if not, you want to roll back the animation progress.
If the user pans through less than 50% of the required distance, you call cancel() — an inherited method — to animate the transition back to its initial state. If the user pans through more than 50% of the distance, you call finish(), which plays the animation the rest of the way through.
These two states are illustrated below:
Because you’re using layer animations, there is a little bit more work to do here. Remember you’d frozen the layer and were updating it manually; when you cancel or complete the transition you need to un-freeze it.
Override cancel() and finish() like so:
override func cancel() {
if isLayerBased {
restart(forFinishing: false)
}
super.cancel()
}
override func finish() {
if isLayerBased {
restart(forFinishing: true)
}
super.finish()
}
private func restart(forFinishing: Bool) {
let transitionLayer = storedContext?.containerView.layer
transitionLayer?.beginTime = CACurrentMediaTime()
transitionLayer?.speed = forFinishing ? 1 : -1
}
When cancelling, you need to set the layer running backwards, so the speed is set to -1.
Build and run the app, and try panning part way and most of the way to see the difference.
Did you notice that when you panned through the reveal animation, you couldn’t go back to the list? That’s because you set interactive to true in handlePan(_:), and you never reset it to false!
Therefore, when you pop the view controller, you return an interaction controller from the delegate method that’s never updated — and your pop transition gets stuck at 0% progress.
The correct spot to reset the interactive property is when the pan gesture ends.
Add the following code to the .cancelled, .ended case:
interactive = false
This should let you pop back to the initial screen. Build and run again, and you should be able to move back and forth.
You’ve completed your Logo Reveal project. RevealAnimator is now performing two different transition animations, including one that’s interactive! Great job! This wraps up the section on view controller transitions. Congratulations on working through this section; these transition APIs are not easy to learn, but all the effort has been worth it.
Key Points
- By adopting the
UIPercentDrivenInteractiveTransitionprotocol in your transition animator, you can easily add interactivity to your custom transitions. - Interactive transitions are usually driven by user gestures. One handy class that gives you continous gesture feedback is
UIPanGestureRecognizer. - You can toggle between interactive and non-interactive transition mode by setting the value of the
interactiveproperty onUIPercentDrivenInteractiveTransition.
Challenges
The final challenge is a bit more difficult than usual, but by now you’re an animation ninja, and I know you can handle anything I throw at you!
Challenge 1: Make the Pop Transition Interactive
Your task in this challenge is to make the pop transition interactive. That’s not as easy as it sounds, as you’ll need to change code in a number of places throughout the project.
The challenge directions below are just broad strokes, so you’ll need to plan your approach before you start coding.
First, in DetailViewController; make a weak property to hold the animator and fetch the animator object from MainViewController. You can access MainViewController from the navigation controller stack.
Once you do that, add a pan gesture handler to DetailViewController. Your handler should be almost identical to the method in MainViewController with one small difference: it should pop the current view controller rather than invoke a segue.
You’ll end up with a cool interactive pop transition — and don’t forget to make sure that tapping the Back button in the navigation bar still works!