Interactive Transitions

 ·  iOS, Development, Tutorials  ·  Tagged UIViewController, transitions, animations, interactive and UIPercentDrivenInteractiveTransition

In my previous post, I explained how to create custom animated transitions. This time, I'm going to show how to make interactive transitions.

As a quick review from last time, there are three main roles involved in an animated transition: the from and to view controllers and the animation controller. In a non-interactive transition, the animation controller defines a duration and sets up the animations between the two views. Both views are placed inside of a container view during the transition.

Interactive transitions build on this structure by adding a fourth role: the interaction controller. This new role is played by an object that conforms to the UIViewControllerInteractiveTransitioning protocol.

Apple provides an interaction controller you can use directly called UIPercentDrivenInteractiveTransition. One way to understand what it does is to imagine that the animation controller sets up an animation timeline, and then UIPercentDrivenInteractiveTransition scrubs the playhead back and forth along the timeline. It's up to you to decide how user input should map to the percent-driven interaction controller's percentComplete property, which determines the position of the metaphorical playhead.

This example only covers how to use UIPercentDrivenInteractiveTransition, but you can also implement your own interaction controllers if needed.

Interactive transitions are available in all the same cases as custom animated transitions: navigation controller push & pop, changing tabs in a tab bar controller, and modal presentations. Since the same concepts apply to all of these cases, I am only going to present a single example: a navigation controller pop transition with a pinch gesture to control a scale and fade animation.

Implementing the Interactive Transition

There are several phases to this interactive transition:

  1. Detect that user input has begun.
  2. Start the transition.
  3. Return the animation controller from the navigation controller's delegate.
  4. Return the interaction controller from the navigation controller's delegate.
  5. Update the interaction controller upon subsequent user input.
  6. Tell the interaction controller to finish or cancel the transition when user input stops.

Detect User Input

Most commonly, the beginning of user input is the start of some kind of continuous touch gesture. I added a UIPinchGestureRecognizer instance to my view controller's view. I will discuss this implementation pattern a little later, but for now, all you need know is that TWTPopTransitionController manages the gesture recognizer logic and also plays the roles of the animation controller and interaction controller.

In TWTPopTransitionController.m:

- (id)init
{
    self = [super init];
    if (self) {
        _pinchGestureRecognizer = [[UIPinchGestureRecognizer alloc] init];
        [_pinchGestureRecognizer addTarget:self action:@selector(handlePinchGesture:)];
    }
    return self;
}

In TWTViewController.m:

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.view.backgroundColor = [UIColor twt_nextColor];

    if (self.popTransitionController.pinchGestureRecognizer) {
        [self.view addGestureRecognizer:self.popTransitionController.pinchGestureRecognizer];
    }
}

Start the Transition

As soon as the interaction begins, you need to trigger the view controller transition. I set up my view controller to call ‑popViewControllerAnimated: when the pinch gesture recognizer's state changes to UIGestureRecognizerStateBegan.

In TWTPopTransitionController.m:

- (void)handlePinchGesture:(UIPinchGestureRecognizer *)pinchGestureRecognizer
{
    
    switch (pinchGestureRecognizer.state) {
        case UIGestureRecognizerStateBegan:
        {
            self.interactive = YES;
            [self.delegate transitionControllerInteractionDidStart:self];
            break;
        }
        
    }
}

In TWTViewController.m:

- (void)transitionControllerInteractionDidStart:(id<TWTTransitionController>)transitionController
{
    [self.navigationController popViewControllerAnimated:YES];
}

Return the Animation Controller

When the transition starts, the navigation controller asks its delegate for an animation controller. This is required. Otherwise, the navigation controller will not ask for an interaction controller. I set up my navigation controller delegate to return an animation controller that scales down and fades out the from view controller's view.

One important caveat here is that if you are using UIPercentDrivenInteractiveTransition for your interaction controller, your animation controller must use the UIView block-based animation API to set up the animation timeline (see WWDC 2013 session 218 slide 151). Animations you set up with Core Animation directly or even with the +[UIView transitionFromView:​toView:​duration:​options:​completion:] method will not be managed by the UIPercentDrivenInteractiveTransition.

Another point to keep in mind is that the animation completion blocks do not get called until the end of the interactive portion of the transition, so you cannot chain animation blocks.

Return the Interaction Controller

Next, the navigation controller will ask its delegate for an interaction controller. Take care to only return an interaction controller if the transition will actually be interactive. In this example, an interactive pop transition can be triggered with a pinch gesture or a non-interactive one can be triggered by tapping the back button. At one point during development, I accidentally returned the interaction controller in both cases, and in the non-interactive case, there was nothing in place to ever update the interaction controller's percentComplete. This left the app frozen in a state that did not allow any user interaction.

Update the Interaction Controller

After the animation and interaction controllers are supplied to the navigation controller, it's off to the races. The animation controller's ‑animateTransition: method will set up the animation timeline, and the pinch gesture recognizer will continue to report updates with state UIGestureRecognizerStateChanged. I set up the pinch gesture recognizer's handler so that as the user input changes it updates the interaction controller's percentComplete property. The interaction controller takes the percent complete value and scrubs to the appropriate point along the animation timeline. The percent complete does not have to change in a single direction; it can increase or decrease at each update.

In TWTPopTransitionController.m:

- (void)handlePinchGesture:(UIPinchGestureRecognizer *)pinchGestureRecognizer
{
    CGFloat scale = pinchGestureRecognizer.scale;
    CGFloat velocity = pinchGestureRecognizer.velocity;

    switch (pinchGestureRecognizer.state) {
        
        case UIGestureRecognizerStateChanged:
        {
            CGFloat percentComplete = 1.0 - scale;
            [self updateInteractiveTransition:percentComplete];
            break;
        }
        
    }
}

Complete the Transition

Eventually, the pinch gesture will report that its state has changed to either UIGestureRecognizerStateEnded or UIGestureRecognizerStateCancelled. I made the handler determine whether the gesture is progressing in a way that indicates that the user wants to complete the transition or cancel the transition, and I call the appropriate method on the interaction controller. The interaction controller then animates the views from their current positions on the animation timeline to either the end (in the case of completing the transition) or back to the beginning (in the case of canceling the transition). There is a bug in the iOS Simulator that causes these final animations to play twice. Until Apple resolves this issue, you will need to test on the device to see how it is supposed to work.

One other unexpected behavior that I encountered was that when a transition is canceled, the properties of the views that you animate are returned to their pre-animation state before the completion block is called. This surprised me since it is the only case that I'm aware of in which the block-based animation API changes the model layer in ways other than adding animation objects.

You may be wondering, as I did, how UIPercentDrivenInteractiveTransition is able to scrub through an animation. Through some careful inspection with the debugger, you can see that the basic idea is that it is setting the speed of the container view's layer to 0 and then, as the percent complete changes, it adjusts the layer's timeOffset to (animationDuration * percentComplete) which effectively scrubs along the animation timeline.

I recently presented the results of my interactive transition explorations to the rest of the iOS team at Two Toasters, and another good question that was raised was "What is the purpose of the animation duration returned by the animation controller if the transition is interactive?" The answer is that it comes in to play during the animated portion of the transition after interaction is complete. If the animation duration is 1.0 second and you finish the transition when percentComplete is 20%, then the completion animation will last 1.0s × (100% − 20%) = 0.8s. Similarly, if instead you cancel the transition, the animation will last 0.2s.

Working Toward a Reusable Implementation

I like to organize code in a way that allows for easy and sensible reusability. It has been a challenge to find a good pattern for reusability when it comes to interactive transitions. The parts of this example that seemed the most valuable to reuse were the animation controller's ‑animateTransition: method and the pinch gesture recognizer's event handling logic.

Initially, after trying several approaches that were less than satisfactory, I came to a design in which the animation controller was a stand-alone NSObject subclass and the gesture recognizer and interaction controller were managed by what I called a transition coordinator. In theory, this pattern allowed mixing and matching interaction logic with animation styles. It worked, but it was still more complicated that I thought was necessary, especially since it seemed likely that the interaction logic would usually need to be tailored to a specific animation.

For that reason, I tried another approach in which the animation controller, interaction controller, and interaction logic are all implemented by a single object that I named the transition controller. This approach significantly simplified the design and made the most sense of the various approaches that I tried.

You can find the full example project including my final implementation pattern on GitHub.

If you have comments on the article or examples of creative interactive transitions you've built (for example, using something other than a touch gesture as an input source) I'd love to hear about it. Get in touch on Twitter: @a_hershberger.