Custom Navigation Transitions, Part IV: An Interactive Pop Transition
May 23, 2019
– iOS, animation, code
This is the fourth and final post in a series on navigation transitions, one of the trickier parts of building a great iOS app. In the first post, we simplified the cast-of-characters in the API. In the second, we built a simple, self-contained modal transition. In the third, we built a more-complex push/pop animation. Today, we’ll tackle the most-challenging one of all: an interactive navigation transition, by implementing the drag-to-dismiss gesture from the Photos app.
Here’s the Nielsen Norman Group’s definition of direct manipulation (emphasis mine):
“Direct manipulation is an interaction style in which the objects of interest in the UI are visible and can be acted upon via physical, reversible, incremental actions that receive immediate feedback.”
Today, we’re going to build exactly that: the drag-to-dismiss transition from Locket Photos, which mimics the same interaction from Apple’s Photos app.
This post is a culmination of the others in this series. To build it, we’ll not only need to support the many custom navigation APIs that we’ve previously discussed — we also need to drive the interaction ourselves, by calculating progress from a gesture recognizer, reporting it to the system, and cleaning up when we’re done.1
Despite the difficulty, these gesture-driven navigation transitions are completely worth it. They take your app from lifeless paperwork to feeling like a playful, crafted product, and that can make all the difference in the perceived quality of your app.
So: let’s dig in!
How an interactive animation works
Let’s start by talking about the order-of-operations for getting an interactive navigation transition to occur:2
The pan-gesture on our photo-detail screen starts, so we call self.navigationController?.popViewController(animated: true).
In our navigation controller, the system asks if we’ve got an animated transition for the current navigation operation. We’ll create and return our interactive animation here.
The system then tells our interactive animation to begin (via startInteractiveTransition) — and now autopilot is off, and we get to drive the animation ourselves. We’ll use the pan gesture to drive the percentage-complete for the animation, and decide whether to update, cancel, or finish the transition.
When the transition is over, it’s either complete, or it’s been cancelled. We’ll animate to the end-state, and notify the system that we’re all done.
Composing the animation
In our last post, we built out non-interactive push and pop animations — so today, we’re going to do two things: add a UIPanGestureRecognizer to our photo-detail screen, and build a new PhotoDetailInteractiveDismissTransition to drive the animation:
We’ve already built a few UIViewControllerAnimatedTransitioning animations with UIViewPropertyAnimator, so we’re going to find that we’re able to reuse those skills today. Yay!
Designing the animation
For Locket Photos, I wanted to closely-mimic the drag-to-dismiss gesture from the Photos app. Instead of having to tap the back button, you can drag down on the image to pop back from the photo-detail screen.
Let’s look at our fish and see some of the details in this interactive transition.
Once interactive, you can drag the photo around anywhere on the screen: it’s not on a vertical rail, it follows your finger. This is direct manipulation, and it feels great.
During the gesture, we need some affordances to imply what will happen if the user lifts their finger. We’ll scale the image down, and decrease the opacity of the photo-detail screen.
If the user drags above the start point, that cancels the gesture — so we’d want the image at full-scale and the background to be opaque.
We don’t want to over-shrink the image, so after a certain drag-distance, we’ll want to just have the image shrunk and the background transparent, until the user completes the gesture.
When the user lets go, we need to figure out whether to cancel or complete the transition, and animate to the end state.
The tab bar shows and hides during the transition, but we already wrote the code for this last time!
We need it to feel great in-hand, so I recommend using SwiftTweaks to dial-in the animation timings and other thresholds.
Building it out in code
It’s all going to start with a pan-gesture in our photo-detail view controller:
When the pan gesture fires, it calls popViewController(animated:), so the system will ask our navigation controller if it has an animated transition. We do! We just need to decide if it’s interactive or not — so we’ll check if the pan-gesture is active on our photo-detail screen to decide whether to return the interactive or non-interactive pop animation:
Next, the system is going to ask our navigation controller if it has an interactive animated transition. (I know, it feels a little repetitive, right?) We held on to our currentAnimationTransition for a reason: to return it here:
Now we hop over to our PhotoDetailInteractiveDismissTransition. Like the animated transitions we built last time, it uses the PhotoDetailTransitionAnimatorDelegate to coordinate with the photo-detail and photo-grid screens. It implements the functions for UIViewControllerAnimatedTransitioning and UIViewControllerInteractiveTransitioning. Also, we have a “update progress” function that we’ll use with our pan-gesture to drive the percentage-complete for the transition.
Let’s start with some pseudocode, so you can see a high-altitude view of what’s going on:
…and that’s it! Let’s go fill in those TODOs.
Filling in the animation’s details
Let’s follow the lifecycle of the transition — so we’ll begin in startInteractiveTransition(_). We’re going to pick apart some of the properties from the transitionContext, notify our delegates that the animation has started (so they can hide their imageviews), and set up the view hierarchy for the transition.
Phew! Ok, after the system calls startInteractiveTransition, we’re in control of the transition. Our pan-gesture will continue to send updates to didPanWith(gestureRecognizer:), so let’s hop up there: We’ll inspect the gesture, and decide whether to update progress, cancel the transition, or complete it.
First, we’re going to need to decide how the pan gesture’s translation maps to a percentage-complete:
That CGFloat function is a nifty way to convert the drag-distance into a value between 0 and 1. (This is one of my “You Deserve Nice Things” bits that I use often in projects.)
Cool. Let’s hop in and implement the guts of our didPanWith(gestureRecognizer:) function from earlier:
Now all we need to do is fill-in our completeTransition(didCancel:) function! This function will set up a property animator to complete or cancel the transition - and it’ll update the property animators on our tab bar and backgroundAnimation, too!
…and you’re done!!
omg, you did it! We’ve built out a non-trivial, production-quality interactive navigation transition. Congratulations!! This was a lot of work; thanks for sticking with it.
I hope that the source code will provide a great reference for you when you’re building out these transitions yourself!
It’s been really nice to get back into writing regularly here.3 I’m going to keep this going. Follow along @devsignblog on Twitter, or subscribe to this site via RSS. Thanks for reading!
At first, I found it a challenge to figure out where I was meant to put that gesture recognizer — does it live in my navigation controller, or the photo-detail screen, or the transition animation? I’m happy with where I wound up, but I imagine that your choice might vary depending on your app’s architecture. ↩
This dance was much harder to coordinate before UIViewPropertyAnimator came to town. We’re going to find it much easier to update, cancel, and complete our animation by using property animators! ↩