Custom View Controller transition by expanding view

I was looking for inspirations on dribbble and found a work that shows nice transition between view controllers by expanding a view (e.g. a button) in every direction. You can find it here: Login & Home Screen.

It is more like Android material design style animation, but I think it may fit nicely in any onboarding process animations.

The animation

Let’s disassemble this animation.

Custom transitions in iOS

One way to approach the problem is to create custom transition animation. This can be done by UIViewControllerAnimatedTransitioning and UIViewControllerTransitioningDelegate protocols.

UIViewControllerAnimatedTransitioning - The protocol is used by animator, the class that knows how the animation look like.

UIViewControllerTransitioningDelegate - The protocol is used by transitioning delegate. It knows what is the total length of the animation and can instantiate and return animators for both presenting and dismissing view controllers.

In this case I’ll only care about presenting animation.

Example project

You can find demo project in ExpandingViewTransition-Demo repository on github.

Transitioning delegate

At first, we have to create transitioning delegate that will return animators for presenting and dismissing view controller (we care only about presenting now).

import UIKit

class ExpandingViewTransition: NSObject, UIViewControllerTransitioningDelegate {

    private let expandableView: UIView
    private let expandViewAnimationDuration: NSTimeInterval
    private let presentVCAnimationDuration: NSTimeInterval

    init(expandingView: UIView,
         expandViewAnimationDuration: NSTimeInterval = 0.35,
         presentVCAnimationDuration: NSTimeInterval = 0.35) {
        self.expandableView = expandingView
        self.expandViewAnimationDuration = expandViewAnimationDuration
        self.presentVCAnimationDuration = presentVCAnimationDuration
    }

    func animationControllerForPresentedController(
        presented: UIViewController,
        presentingController presenting: UIViewController,
                             sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {

        return ExpandingViewTransitionAnimatorPresent(expandableView: self.expandableView,
                                                      expandViewAnimationDuration: self.expandViewAnimationDuration,
                                                      presentVCAnimationDuration: self.presentVCAnimationDuration)
    }

    func animationControllerForDismissedController(
        dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return nil
    }
}

The init(expandableView:expandViewAnimationDuration:presentVCAnimationDuration:) takes reference to view that will be expanded (button), and animation durations that specifies how long expanding process is and how long is fade of destination view controller after expandable view is fully expanded.

Two remaining methods are the one from UIViewControllerTransitioningDelegate. First returns ExpandingViewTransitionAnimatorPresent animator which is used to present new view controller.

Second method returns nil as we don’t care about dismissing animation. Default dismiss animation will be used in this case.

Animator

The foundation of animator look like this.

class ExpandingViewTransitionAnimatorPresent: NSObject, UIViewControllerAnimatedTransitioning {
  private let expandableView: UIView
  private let expandViewAnimationDuration: NSTimeInterval
  private let presentVCAnimationDuration: NSTimeInterval

  init (expandableView: UIView,
        expandViewAnimationDuration: NSTimeInterval,
        presentVCAnimationDuration: NSTimeInterval) {
      self.expandableView = expandableView
      self.expandViewAnimationDuration = expandViewAnimationDuration
      self.presentVCAnimationDuration = presentVCAnimationDuration
  }

  func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
      return self.expandViewAnimationDuration + self.presentVCAnimationDuration
  }

  func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
      // ...
  }
}

The first thing is to calculate how much the expandable view should expand. The idea is simple. We need to check how far the view is from the edges of the screen, take the longest distance and use it to calculate scale that should be applied.

After we find such maximal offset, then we look for the biggest dimension of the expandable view and for longest dimension of the source view controller which should be full screen - I think we could use dimension of UIScreen.mainScreen().bounds too. It might work even better for some cases.

With such informations we’re able to calculate new scale properly.

/**
 The method calculates a scale that expandable view should transform to
 in order to fill entire screen no matter where it is located on the screen.
 */
private func calculateFinalTransformOfExpandingViewInSourceVC(
    expandingView: UIView,
    sourceVC: UIViewController) -> CGAffineTransform {

    // left, right, top, bottom
    let offsets = [expandingView.frame.origin.x,
                   sourceVC.view.bounds.width - expandingView.frame.origin.x,
                   expandingView.frame.origin.y,
                   sourceVC.view.bounds.height - expandingView.frame.origin.y]

    let minExpandingViewDim = min(expandableView.bounds.width,
                                  expandableView.bounds.height)

    let maxOffsetVal = offsets.maxElement()!
    let maxSourceDim = max(sourceVC.view.bounds.width,
                           sourceVC.view.bounds.height)

    // to make sure that source is filled by `expandingView`
    // especially if the view has rounded edges
    let threshold_scale: CGFloat = 2

    let scale = (maxSourceDim + maxOffsetVal) / minExpandingViewDim + threshold_scale
    return CGAffineTransformMakeScale(scale, scale)
}

Here is how the transition code look like.

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
    /**
     Transitions involves:
     - adding source and destination view controller to the container view
     - transforming expandable view so it fills entire screen
     - finally displaying the destination view controller
     - bringing back previous state of expandable view
     */

    let sourceVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!

    let destinationVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
    destinationVC.modalPresentationStyle = .Custom
    destinationVC.view.alpha = 0

    let containerView = transitionContext.containerView()!
    containerView.addSubview(sourceVC.view)
    containerView.addSubview(destinationVC.view)

    let beginZPosition = self.expandableView.layer.zPosition
    let beginTransform = self.expandableView.transform

    let finalTransform = calculateFinalTransformOfExpandingViewInSourceVC(self.expandableView, sourceVC: sourceVC)

    self.expandableView.layer.zPosition = CGFloat.max - 1

    UIView.animateWithDuration(self.expandViewAnimationDuration, delay: 0, options: .CurveEaseIn, animations: {
        self.expandableView.transform = finalTransform
        }, completion: { _ in
            UIView.animateWithDuration(self.presentVCAnimationDuration, animations: {
                destinationVC.view.alpha = 1
                }, completion: { _ in
                    self.expandableView.transform = beginTransform
                    self.expandableView.layer.zPosition = beginZPosition

                    transitionContext.completeTransition(true)
            })
    })
}

Using UITransitionContextFromViewControllerKey and UITransitionContextToViewControllerKey keys, we can obtain source and destination view controllers via viewControllerForKey method.

Then as documentation says presentation style of destination view controller is set to custom - destinationVC.modalPresentationStyle = .Custom.

Animator provides us containerView via transitionContext - see UIViewControllerContextTransitioning for details. Source and destination view controllers are added to this container view.

We have to store somewhere begin state of zPosition and layer.transform of expandable view so we can bring it back to the state before scaling.

After this there is just simple animation of a transform of expandable view and after this finished, the destination view controller is presented.

After all of this is done and only destination view controller is on screen we can bring previous appearance of the expandable view.

When everything is done you have to mark that the transition completed.

transitionContext.completeTransition(true)

transition-2 transition-4

How to use it?

Here is one way you can use it programatically:

@IBAction func doAnimate(sender: UIView) {

    let transitionDelegate = ExpandingViewTransition(expandingView: sender,
                                                     expandViewAnimationDuration: 0.4,
                                                     presentVCAnimationDuration: 0.1)

    let storyboard = UIStoryboard(name: "Main", bundle: nil)

    let vc = storyboard.instantiateViewControllerWithIdentifier("SecondViewController")
    vc.transitioningDelegate = transitionDelegate

    self.presentViewController(vc, animated: true, completion: nil)
}

--
2015-2018 All rights reserved. Tomasz Szulc