Skip to content

Latest commit

 

History

History
259 lines (207 loc) · 12 KB

2013-10-08-view-controller-transitions.md

File metadata and controls

259 lines (207 loc) · 12 KB
layout title category date tags author
post
View Controller Transitions
5
2013-10-07 09:00:00
article

Custom Animations

One of the most exciting iOS 7 features for me is the new View Controller Transitioning API. Before iOS 7, I would also create custom transitions between view controllers, but doing this was not really supported, and a bit painful. Making those transitions interactive, however, was harder.

Before we continue with the article, I'd like to issue a warning: this is a very new API, and while we normally try to write about best practices, there are no clear best practices yet. It'll probably take a few months, at least, to figure them out. This article is not so much a recommendation of best practices, but rather an exploration of a new feature. Please contact us if you find out better ways of using this API, so we can update this article.

Before we look at the API, note how the default behavior of navigation controllers in iOS 7 changed: the animation between two view controllers in a navigation controller looks slightly different, and it is interactive. For example, to pop a view controller, you can now pan from the left edge of the screen and interactively drag the current view controller to the right.

That said, let's have a look at the API. What I found interesting is the heavy use of protocols and not concrete objects. While it felt a bit weird at first, I prefer this kind of API. It gives us as programmers much more flexibility. First, let's try to do a very simple thing: having a custom animation when pushing a view controller in a navigation controller (the sample project for this article is on github). To do this, we have to implement one of the new UINavigationControllerDelegate methods:

- (id<UIViewControllerAnimatedTransitioning>)
                   navigationController:(UINavigationController *)navigationController
        animationControllerForOperation:(UINavigationControllerOperation)operation
                     fromViewController:(UIViewController*)fromVC
                       toViewController:(UIViewController*)toVC
{
    if (operation == UINavigationControllerOperationPush) {
        return self.animator;
    }
    return nil;
}

We can look at the kind of operation (either push or pop) and return a different animator based on that. Or, if we want to share code, it might be the same object, and we might store the operation in a property. We might also create a new object for each operation. There's a lot of flexibility here.

To perform the animation, we create a custom object that implements the UIViewControllerAnimatedTransitioning protocol:

@interface Animator : NSObject <UIViewControllerAnimatedTransitioning>

@end

The protocol requires us to implement two methods, one for the animation duration:

- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext
{
    return 0.25;
}

and one that performs the animation:

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
    UIViewController* toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    [[transitionContext containerView] addSubview:toViewController.view];
    toViewController.view.alpha = 0;
    
    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromViewController.view.transform = CGAffineTransformMakeScale(0.1, 0.1);
        toViewController.view.alpha = 1;
    } completion:^(BOOL finished) {
        fromViewController.view.transform = CGAffineTransformIdentity;
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
        
    }];

}

Here, you can see how the protocols are used: instead of giving a concrete object with properties, this methods gets a transition context that is of type id<UIViewControllerContextTransitioning>. The only thing that's extremely important, is that we call completeTransition: after we're done with the animation. This tells the animation context that we're done and updates the view controller's state accordingly. The other code is standard; we ask the transition context for the two view controllers, and just use plain UIView animations. That's really all there is to it, and now we have a custom zooming animation.

Note that we only have specified a custom transition for the push animation. For the pop animation, iOS falls back to the default sliding animation. Also, by implementing this method, the transition is not interactive anymore. Let's fix that.

Interactive Animations

Making this animation interactive is really simple. We need to override another new navigation controller delegate method:

- (id <UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController*)navigationController
                          interactionControllerForAnimationController:(id <UIViewControllerAnimatedTransitioning>)animationController
{
    return self.interactionController;
}

Note that, in a non-interactive animation, this will return nil.

The interaction controller is an instance of UIPercentDrivenInteractiveTransition. No further configuration or setup is necessary. We create a pan recognizer, and here's the code that handles the panning:

if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
    if (location.x >  CGRectGetMidX(view.bounds)) {
        navigationControllerDelegate.interactionController = [[UIPercentDrivenInteractiveTransition alloc] init];
        [self performSegueWithIdentifier:PushSegueIdentifier sender:self];
    }
} 

Only when the user is on the right-hand side of the screen, do we set the next animation to be interactive (by setting the interactionController property). Then we just perform the segue (or if you're not using storyboards, push the view controller). To drive the transition, we call a method on the interaction controller:

else if (panGestureRecognizer.state == UIGestureRecognizerStateChanged) {
    CGFloat d = (translation.x / CGRectGetWidth(view.bounds)) * -1;
    [interactionController updateInteractiveTransition:d];
} 

This will set the percentage based on how much we have panned. The really cool thing here is that the interaction controller cooperates with the animation controller, and because we used a normal UIView animation, it controls the progression of the animation. We don't need to connect the interaction controller to the animation controller, as all of this happens automatically in a decoupled way.

Finally, when the gesture recognizer ends or is canceled, we need to call the appropriate methods on the interaction controller:

else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) {
    if ([panGestureRecognizer velocityInView:view].x < 0) {
        [interactionController finishInteractiveTransition];
    } else {
        [interactionController cancelInteractiveTransition];
    }
    navigationControllerDelegate.interactionController = nil;
}

It's important that we set the interaction controller to nil after the transition was completed or cancelled. If the next transition is non-interactive, we don't want to return our old interaction controller.

Now we have a fully interactive custom transition. Using just plain gesture recognizers and a concrete object provided by UIKit, we achieve this in only a few lines of code. For most custom transitions, you can probably stop reading here and do everything with the methods described above. However, if you want to have completely custom animations or interactions, this is also possible. We'll look at that in the next section.

Custom Animation Using GPUImage

One of the cool things we can do now is completely custom animations, bypassing UIView and even Core Animation. Just do all the animation yourself, Letterpress-style. In a first attempt, I did this with Core Image, however, on my old iPhone 4 I only managed to get around 9 FPS, which is definitely too far off the 60 FPS I wanted.

However, after bringing in GPUImage, it was simple to have a custom animation with really nice effects. The animation we want is pixelating and dissolving the two view controls into each other. The approach is to take a snapshot of the two view controllers, and apply GPUIImage's image filters on the two snapshots.

First, we create a custom class that implements both the animation and interactive transition protocols:

@interface GPUImageAnimator : NSObject
  <UIViewControllerAnimatedTransitioning,
   UIViewControllerInteractiveTransitioning>

@property (nonatomic) BOOL interactive;
@property (nonatomic) CGFloat progress;

- (void)finishInteractiveTransition;
- (void)cancelInteractiveTransition;

@end

To make the animations perform really fast, we want to upload the images to the GPU once, and then do all the processing and drawing on the GPU, without going back to the CPU (the data transfer will be very slow). By using a GPUImageView, we can do the drawing in OpenGL (without having to do manual OpenGL code; we can keep writing high-level code).

Creating the filter chain is very straightforward. Have a look at setup in the sample code to see how to do it. A bit more challenging is animating the filters. With GPUImage, we don't get automatic animation, so we want to update our filters at each frame that's rendered. We can use the CADisplayLink class to do this:

self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(frame:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

In the frame: method, we can update our progress based on how much time has elapsed, and update the filters accordingly:

- (void)frame:(CADisplayLink*)link
{
    self.progress = MAX(0, MIN((link.timestamp - self.startTime) / duration, 1));
    self.blend.mix = self.progress;
    self.sourcePixellateFilter.fractionalWidthOfAPixel = self.progress *0.1;
    self.targetPixellateFilter.fractionalWidthOfAPixel = (1- self.progress)*0.1;
    [self triggerRenderOfNextFrame];
}

And that's pretty much all we need to do. In case of interactive transitions, we need to make sure that we set the progress based on our gesture recognizer, not based on time, but the rest of the code is pretty much the same.

This is really powerful, and you can use any of the existing filters in GPUImage, or write your own OpenGL shaders to achieve this.

Conclusion

We only looked at animating between two view controllers in a navigation controller, but you can do the same for tab bar controllers or your own custom container view controllers. Also, the UICollectionViewController is now extended in such a way that you can automatically and interactively animate between layouts, using the same mechanism. This is really powerful.

When talking to Orta about this API, he mentioned that he already uses it a lot to create lighter view controllers. Instead of managing state within your view controller, you can just create a new view controller and have a custom animation between the two, moving views between the two view controllers during the transition.

More Reading