Circular Reveal View Controller Transition

Whenever you get asked to make animations the first thought is panic. And second is to search for a similar animation that has already been made and just copy it or “pod install” it. Making animations sounds more scary than it has to be.

Every animation occurs over a period of time. When working with animations all you have to do is break it down into several steps that occur over said period.

Let us look at something basic, moving a UIView object from point A to B.

UIView.animate(withDuration : 0.3, animations: {
self.tempView.frame = CGRect.zero
}) { (finished) in }

Even for custom transitions, that is what the final block that actually executes the animation you want looks like (of course, after changing the code inside to your needs)! Everything else is delegate conformation to let iOS know what class does which action and from where it needs to fetch the needed information. All it takes is a bit of creativity to imagine how individual components look like to offer a seamless experience to the user.

Note: There is only one difference between creating custom transitions for pushing onto the navigation stack and doing a modal presentation that I will point out later.

The animation that I will be “faking” so to speak is one that I used before in a mobile application and quite liked it. For our purpose, it will show you how to fake behaviours and run different animations together to make it look complete.

This is the final result:

The first thing to have ready, are the assets. The UITabBar has a plus icon is a bit above the others, so the icon itself should have a vertical offset. For the animation calculations to be a bit easier (and for this tutorial), I would recommend having a copy of the plus icon without the offset. The next icon you need is the cross icon that will close the screen (with and without the offsets). The offsets are necessary to make the calculations easier. Also do bear in mind, that when you create the presented view controller, you add a bottom constraint to the safe area with a -3.5 height (This is to overcome the difference between the offset of the asset and height of the safe area. There are better ways to fix this like taking a higher Y offset for the initial image). You will have to play around with the calculations a bit to fit your needs. When lost just have a look at the example at the end to see what is different.

To start with, add in a UITabBarController with 3 child view controllers and subclass the UITabBarController.

  1. We assign the UITabBarController as its own delegate to override behaviour for the plus button action as we want to present a new screen instead of displaying the embedded UIViewController.
  2. We need to define this explicitly to remove any customisations that apple does. You can remove this if you want to include apple’s default transition as well (the presenting view controller going backwards transition).
  3. This is necessary so that we don’t show the embedded view controller.

Also, you obviously need to create another view controller with the cross icon to close the screen.

That is the basic setup done. In the next section we will start with the animation part.

In the above gist, just above the line with the comment 2, add the following:

viewController.transitioningDelegate = self

This code will inform UIKit that we want manual control for the viewController transition. So naturally we need to make sure the UITabBarController subclass conforms to this delegate.

Note: Returning nil ensures that the default animation is called.

If you want to do a custom transition for when pushing a view controller instead of presenting, all you have to do is:

Change this:

viewController.transitioningDelegate = self

to

//this will be the navigation controller performing the push //operation
navigationController.delegate = self

and change the extension to:

Create a new class called CircleFadeInAnimator or anything that you want really. Make sure it is a subclass of UIViewController and conforms to UIViewControllerAnimatedTransitioning. This will be our animator object.

Now comes the fun part. All of the heavy lifting will be done inside the animateTransition method. What you can imagine the screen to be like is:

There is a container that is equal to the screen bounds that is added automatically by UIKit as soon as you call the present API. It contains the viewController’s view that is presenting the new UIViewController which in our case is the UITabBarController. Inside the animateTransition method you have the transitionContext. This transitionContext contains majority of the information you will need to carry out the animation.

Now, that the conformation part is out of the way, let us focus on breaking down the animation. It begins by the user pressing on the tabBarItem. It pulses out to reveal the presented view controller while simultaneously changing the plus to a cross. So our first major animation is the pulse and then the icon change. Steps to achieve this:

  1. Add the presented view controller (the orange screen) on top of the presenting view controller. The problem now, is how do we show the plus button?
  2. We know, we can animate CAShapeLayers by changing their paths. To hide an element we can add a layer mask to it. So basically just add it as a layer mask to the presented view controller.
  3. Now there are two problems. How do we show the plus icon? And how on earth do we rotate the tabBarItem? Answer? We don’t. We fake it. Some of you may have guessed, we just programmatically add the shapes on to the container view that UIKit conveniently gives us!

It all starts from the UITabBarItem. To add this button onto our containerView, we need our CircleFadeInAnimator class to obtain this information. So we make an instance variable in our class! Add the following below the class name:

var triggerFrame : CGRect!

Now before we play around in this class, let us go back to the CustomTabBarController class. Change the extension to:

  1. Get the tabBarItem who’s frame we want.
  2. The coordinates of the tabBarItem are relative to its superview. So we need to convert the frame coordinates to the coordinate space of the container view. To do this we simply pass in a UIView with the same dimensions.
  3. Pass the trigger frame to our animator class.

Okay so playing around with all other classes is now over. We come back to our CircleFadeInAnimator class.

Inside the animateTransition method, we need to fetch references for our presented and presenting view controllers. After all we need to manipulate the views of these view controllers. Like I mentioned before, we get all this information from the transitionContext argument. So add the following code inside animateTransition:

guard let toController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
transitionContext.completeTransition(false)
return
}
guard let fromController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {
transitionContext.completeTransition(false)
return
}
let containerView = transitionContext.containerView
let totalDuration = transitionDuration(using: transitionContext)

Next, we add the following lines:

toController.view.frame = containerView.bounds //1containerView.insertSubview(toController.view, aboveSubview: fromController.view) //2
  1. This is just a safety. Sometimes when the status bar is showing on iPhone 8 and 7 devices, the container view ends up being smaller than than the toController.view.
  2. We need to add the presented screen onto the container as it is upto us to handle the entire presenting flow.

We have added the toController onto the screen. Before adding the mask to it, we should setup all the other elements onto the UI.

After the above lines add:

let imageContainer = UIView(frame: triggerFrame)
imageContainer.layer.cornerRadius = imageContainer.bounds.height/2.0
imageContainer.clipsToBounds = true
imageContainer.backgroundColor = UIColor.clear
imageContainer.isUserInteractionEnabled = true

We have just setup a view to contain all our fake buttons that we will be adding onto the UI. Now we need to make the image views with the appropriate icons. Since the code for that is nothing too difficult, i’ll add the final result:

Now everything has been setup and all thats left is to apply the mask! Just below where you add the imageContainer below the containerView, add the following lines:

let initialPath = UIBezierPath(ovalIn: triggerFrame) //1//Destination Path
let fullHeight = toController.view.bounds.height
let finalPath = UIBezierPath(ovalIn: triggerFrame.insetBy(dx: -fullHeight, dy: -fullHeight)) //2
let maskLayer = CAShapeLayer()
toController.view.layer.mask = maskLayer //3
  1. We set the initial path to be the triggerFrame. As we want that part on the screen to remain visible at all times. (Since we will be changing the plus to a cross)
  2. The finalPath needs to be created by increasing the path size using insetBy. I initially thought that creating a path without using insetBy would give the same animation result but it doesn’t for some reason (not sure). Logically it makes a bit of sense since you are increasing the size of sphere by increasing the radius(?).
  3. Create a maskLayer and add it to toController.view. Don’t worry about not setting the path for this layer as we will do that in a second.

Now your paths and fake views are setup we will finally start the animation.

After the above code, add the following:

let maskLayerAnimation = CABasicAnimation(keyPath: "path")
maskLayerAnimation.fromValue = initialPath.cgPath
maskLayerAnimation.toValue = finalPath.cgPath
maskLayerAnimation.duration = totalDuration
maskLayer.add(maskLayerAnimation, forKey: "path")

All of the above seems pretty standard for CABasicAnimation. We need to use CABasicAnimation since we are adding animation on to the layer property.
Finally change the animation block to:

UIView.animate(withDuration: totalDuration, animations: {
initialImageView.alpha = 0.0 //1
finalImageView.alpha = 1.0
}) { (finished) in
toController.view.layer.mask = nil
imageContainer.removeFromSuperview()
transitionContext.completeTransition(true) //2
}
  1. We fade out the original image view containing the plus and fade in the image view containing the cross as we want to end on the presented screen.
  2. This is the most important line. This informs UIKit, that the animation has been completed. Passing true signifies that the animation was not cancelled. So we perform any cleanup we need in the completion block of animate.

Now two things happen simultaneously here. The maskLayerAnimation is executed immediately as soon as we add the animation using :

maskLayer.add(maskLayerAnimation, forKey: "path")

And at the same time the UIView.animate gets called (since it is the next line and we work in the main thread). The totalDuration for both the animations is the same so there is no chance of animations being out of sync either.

We still have one thing remaining though, if you run and play it right now, you can see that the plus doesn’t rotate. Quite a simple fix at this stage. Since we are faking everything by adding temporary UI components, we just need to rotate these components. Below your setupImageView function, add the following:

private func rotateAnimation(view: UIView, duration: CFTimeInterval = 0.5, totalDuration : CFTimeInterval) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat(.pi * 2.0)
rotateAnimation.duration = duration
rotateAnimation.repeatCount = Float(totalDuration/duration)
view.layer.add(rotateAnimation, forKey: nil)
}

And just above the UIView.animate block, add the following lines:

rotateAnimation(view: initialImageView, totalDuration : totalDuration)
rotateAnimation(view: finalImageView, totalDuration : totalDuration)

Now the rotation will work as expected! Just run everything together and your circular fade in animation will work! I haven’t explained the dismiss animation in this tutorial but is available on this GitHub page.

I know animations seem daunting at first but the more you experiment and play around, the better you become. An excellent resource if you want to know what each delegate conformation is for and a more detailed step by step explanation for the UIKit apis, you can check out this doc from apple.

MSc Student and iOS Developer

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store