Last Updated: July 25, 2019
·
25.5K
· Andrej Mihajlov

Custom transitions on iOS 7 & a little bit about UX

Once upon a time I decided to replace standard modal transition with a custom one. Standard transitions look too heavy sometimes. My goal was to keep my app like a one large canvas without clear boundaries between controllers. I didn't plan to make spring-based transition just because it looks cool, I wanted to have a subtle animation, less heavy, more lightweight to make the experience with the app more pleasant.

It's especially important when you have that kind of app that requests some sort of user information before it actually gets user to the point of the app, to the main content of the app, before it gets useful.

Of course you optimize number of steps to ridiculous 3-4, very short, with more advanced features hidden by default, with least information needed to start using the app. But you still have that heavy feeling about couple of controllers popping up.

I was pretty much inspired by Fonoteka app by Zvooq, which is unfortunately available only in Russian store, but if you switch your store, you can get it and it's in English. I see it as a great example of great user experience. Most of interactions happen using swipes, in all 4 directions, and everything kept within single canvas that changes its background from controller to controller, it's very smooth.

Picture

While I understand that such approach is not for every app and not for my app either, I wanted to make a move towards less obtrusive transitions.

Amazing thing that since iOS 7 you can implement your own animator and animate transitions between controllers in whatever way you want. And because it seemed quite simple to implement I decided to organize a hack day (or two) to see the potential. I setup a two days deadline to produce something decent, otherwise I would just bury another one feature branch and forget about it.

However, as usual, especially with Apple, el Diablo en los Detalles.

When I tried to implement my own transition, all things fell apart, from my stable code that worked perfectly fine before to status bar style and unwind segues. Hot & sexy controllers that used to surf the screen surface looked more like, well, sinking ships. :(

I read a lot about custom transitions on the web in past few days, I checked every blog post and every git repo I could find, and so I end up being even more frustrated. Because essentially nobody is doing it right. Well, maybe I don't do it right either, but my tests run fine :)

And so I decided to put this dramatic post on Coderwall and I hope the way of telling this story is appropriate enough to be here as it's a little bit more than a copy-paste of some code. Besides that it's probably one of my first blog posts in English and I never had much patience and time before, so I hope you guys won't hate me till the rest of your life and enjoy this journey with me.

Let's set a standard for proper transition:

  1. Correct handling of appearance events (e.g. no double viewWillAppear and such)
  2. Correct status bar appearance handling
  3. Navigation controllers should work and look fine while transition

<br/>

Transition delegate

The first thing you start with on that long way is adoption of UIViewControllerTransitioningDelegate protocol in your view controller.

Basically this protocol is a contract between UIKit and your controller, so when UIKit needs animator for transition, it comes to your view controller and asks for help.

You simply create an instance of your animator and return it for presentation and dismissal of view controller.

There is a possibility to return self and implement transition code within controller but I like the idea of separating animator from view controller so it can be reused in other parts of the app.

@implementation MYViewController (Transitions)

- (id <UIViewControllerAnimatedTransitioning>)
   animationControllerForPresentedController:(UIViewController *)presented 
   presentingController:(UIViewController *)presenting 
   sourceController:(UIViewController *)source 
{
    return [MYModalTransitionAnimator new];
}

- (id <UIViewControllerAnimatedTransitioning>)
   animationControllerForDismissedController:(UIViewController *)dismissed 
{
    return [MYModalTransitionAnimator new];
}

@end

Segue

The good news that you can create segues in IB, the bad news that you cannot change transition style to custom or set delegate for transition, so you have to catch a segue in code and modify it in prepareForSegue:.

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
    if([segue.identifier isEqualToString:@"MYSegue"]) {
        UIViewController* controller = (UIViewController*)segue.destinationViewController;
        controller.transitioningDelegate = self; // 1
        controller.modalPresentationStyle = UIModalPresentationCustom; // 2
        controller.modalPresentationCapturesStatusBarAppearance = YES; // 3
    }
}
  1. Setup transition delegate to presenting view controller
  2. Setup custom modal presentation style
  3. Let presented controller to take over status bar appearance. This helps to plug-in default preferredStatusBarStyle mechanism for controller presented using custom transition. Even though documentation says that for fullscreen controllers you do not need to do anything, but without that flag it didn't work for me.

Animator's skeleton

Let's take a look at animator's class skeleton:

@ interface MYModalTransitionAnimator : NSObject<UIViewControllerAnimatedTransitioning>
@end

@implementation MYModalTransitionAnimator

- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 0.5; // 1
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext // 2
{
    UIViewController* destination = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];

    if([destination isBeingPresented]) { // 3
        [self animatePresentation:transitionContext]; // 4
    } else {
        [self animateDismissal:transitionContext]; // 5
    }
}

@end
  1. The actual duration of transition, it must match with duration of your CA animation.

  2. TransitionContext is an opaque object that follows specific protocol that allows us to access some of useful information we need for animation. Such as source and destination view controllers, container view (which is UITransitionView, will be discussed later) and transitionComplete: method that we should call once our UIView animation finished.

  3. Some people use flag to identify whether animator should present or dismiss controller. I prefer to query the destination controller to find out what's going on.

  4. & 5 I keep both animations separately in two different methods, for convenience.

<br/>

How it works

So before we roll out some animations I would like to get a bit deeper in the transition process to better understand what is UIKit doing behind the scenes.

So if you ever checked the view hierarchy of UIWindow you probably noticed that at the top of UIWindow we have UITransitionView and one level below it's UILayoutContainerView. Yeah, those magic views god knows why placed there.

UITransitionView is our playground and UILayoutContainerView is a container for our controller's view.

At the point when UIKit calls animateTransition:, everything is set for us to perform animation. Transition context has containerView that points to transition view where we can mess around without being burnt. It also has two of our controllers for transition.

In case if transition happens, let's say implicitly, between two navigation controllers, it returns each of them, because UIStoryboardSegue figured that out for us, so it won't let us animate from one of view controllers of navigation controller to whatever else, but instead it will return navigation controller itself. Which is pretty smart, otherwise your navigation bar would stick to the top and then suddenly disappear.

So let's use debugger and see what kind of setup we have when UIKit calls animator to perform its job. Initially we have transition view, presenting controller that is on screen and modal controller that is not in view hierarchy yet.

(lldb) po source
<MYInitialNavigationController: 0x10a650620>
(lldb) po destination
<MYOtherNavigationController: 0x10a664580>

Okay that seems legit, but wait a minute, one more thing...

(lldb) po source.view
<UILayoutContainerView: 0x10a733430; frame = (0 0; 320 568); autoresize = W+H; gestureRecognizers = <NSArray: 0x10a74c350>; layer = <CALayer: 0x10a733510>>

(lldb) po source.view.superview
<UITransitionView: 0x10a937560; frame = (0 0; 320 568); clipsToBounds = YES; autoresize = H; layer = <CALayer: 0x10a938e30>>

(lldb) po [transitionContext containerView]
<UITransitionView: 0x10a937560; frame = (0 0; 320 568); clipsToBounds = YES; autoresize = H; layer = <CALayer: 0x10a938e30>>

(lldb) po [source.view subviews]
<__NSArrayM 0x10a7456e0>(
<UINavigationTransitionView: 0x10a744ef0; frame = (0 0; 320 568); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x10a744ce0>>,
<UINavigationBar: 0x10a655a80; frame = (0 -44; 320 44); hidden = YES; opaque = NO; autoresize = W; gestureRecognizers = <NSArray: 0x10a80eee0>; layer = <CALayer: 0x10a64f290>>
)

Great, so UIKit just hijacked my view controller, wrapped its view in some UILayoutContainerView and pretends to be me. Let it be then.

That is actually how the view hierarchy looks after we present a view controller with custom modal transition:

Picture

Important difference between presentation and dismissal animations

  1. When we present a modal view controller, source controller is already set and has its superview but destination controller isn't yet. So it's our job to place it into provided containerView. We can do it right away or when we finished with animations.
  2. When we dismiss a modal view controller, both controllers already in view hierarchy and placed inside containerView and we do not have to manage view hierarchy.

Unlike standard modal transitions, where presenting view controller gets removed after animation, both view controllers persist in hierarchy in their own containers. That is one of the problems with missing appearance events (viewWillAppear: & co) for presenting controller. To fix that problem I spin off beginAppearanceTransition:animated: which will call appropriate appearance methods on view controller. First I thought it's wrong, but it seems to do the trick.

Enough theory

Let's do some animations finally. As you can see from the code snippet below, it's very trivial, it boils down to simple animation between two views. The only thing you have to take care of is calling completeTransition: in the end of transition.

- (void)animatePresentation:(id<UIViewControllerContextTransitioning>)transitionContext
{
    UIViewController* source = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController* destination = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView* container = transitionContext.containerView;

    // Take destination view snapshot
    UIView* destinationSS = [destination.view snapshotViewAfterScreenUpdates:YES]; // YES because the view hasn't been rendered yet.

    // Add snapshot view
    [container addSubview:destinationSS];

    // Move destination snapshot back in Z plane
    CATransform3D perspectiveTransform = CATransform3DIdentity;
    perspectiveTransform.m34 = 1.0 / -1000.0;
    perspectiveTransform = CATransform3DTranslate(perspectiveTransform, 0, 0, -100);
    destinationSS.layer.transform = perspectiveTransform;

    // Start appearance transition for source controller
    // Because UIKit does not remove views from hierarchy when transition finished
    [source beginAppearanceTransition:NO animated:YES];

    [UIView animateKeyframesWithDuration:0.5 delay:0.0 options:UIViewKeyframeAnimationOptionCalculationModeCubic animations:^{
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:1.0 animations:^{
            CGRect sourceRect = source.view.frame;
            sourceRect.origin.y = CGRectGetHeight([[UIScreen mainScreen] bounds]);
            source.view.frame = sourceRect;
        }];
        [UIView addKeyframeWithRelativeStartTime:0.2 relativeDuration:0.8 animations:^{
            destinationSS.layer.transform = CATransform3DIdentity;
        }];
    } completion:^(BOOL finished) {
        // Remove destination snapshot
        [destinationSS removeFromSuperview];

        // Add destination controller to view
        [container addSubview:destination.view];

        // Finish transition
        [transitionContext completeTransition:finished];

        // End appearance transition for source controller
        [source endAppearanceTransition];
    }];
}

Keep in mind that you must not move views around after calling completeTransition:.

When animating between navigation controllers, I prefer to take a snapshot of invisible controller because in that case you get proper 64px high navigation bar on snapshot. Otherwise animating navigation controller directly leads to 44px bar while animation and some glitch when it docks to status bar in the end of animation. Another good reason is probably performance, but I don't have numbers.

Dismissal animation is pretty much doing the same but in reverse so I am not posting it here.

Picture

Unwinding

Unwinding surprisingly does not work out-of-box, so you have to call dismissViewControllerAnimated on presented controller. I do this in unwind action. Roughly the implementation looks as following, but you can do better job and at least check segue identifier:

- (IBAction)unwindToRootViewController:(UIStoryboardSegue*)unwindSegue {
    [unwindSegue.sourceViewController dismissViewControllerAnimated:YES completion:nil];
}

Github, Please

You can find the full source code at https://github.com/pronebird/CustomModalTransition. There is a neat sample app available so you can run it and see if things work for you.

Good luck!

4 Responses
Add your response

Cool tutorial, thanks! Can you add the -animateDismissal method to your sample code?

over 1 year ago ·

@thgc Thanks! The full source code is available on Github.

over 1 year ago ·

By far the best explanation I've seen recently. I have a question though, is it possible to change the background layers' color? I'm making a custom transition but I wan't the user to be able to see what's behind my presented View while animating, without the black background.

over 1 year ago ·

@melisadlg I think you can achieve this by changing the background color of UIWindow.

over 1 year ago ·