Development iOS

(Part 2) iOS Custom Transition Animations

June 1, 2018

In Part 1 of this two part series, we talked about how to create the views for the animation below.

In this part, we will start getting into the meat of the problem of how to create a custom transition animation between two views.

Custom Transition Animation

If you are new to custom transition animations, this bit can be a little confusing at first - so let me do my best to break it down a bit.

First you need to know about UIPresentationController which apple describes as:

An object that manages the transition animations and the presentation of view controllers onscreen.

Whether you are aware or not, you cannot have an animated transition without a UIPresentationController. With that said, you do not have to create your own UIPresentationController subclass in order to have a custom transition animation. If you do not supply a custom UIPresentationController, then a default one is created for you behind the scenes. So in other words, one way or another a UIPresentationController will be used.

So if you do not have to create your own UIPresentationController in order to implement a custom transition, then why did I just tell you about UIPresentationController? Well for one, its good to know what is going on behind the scenes. Secondly, you should know when you would want to use one. For example, if you are transitioning between views and you want to manipulate the presented or presenting view beyond the lifetime of the transition, you will need to implement your own custom UIPresentationController. Two excellent examples of this behavior can be seen when opening a side menu or presenting a modal that hovers over the view underneath. In both of those examples we are manipulating the frame of a UIViewController that is being presented and overlaying a semi-transparent view behind it to make the view stand out. Some more great reading material on UIPresentationController can be found here.

In our case, we are not manipulating the presented or presenting view controller beyond the lifetime of the transition so we can safely get by without creating our own UIPresentationController. We do however need to create an object that conforms to the UIViewControllerAnimatedTransitioning protocol. This object (often referred to as a controller), determines the duration of the transition animation and the actual animation itself. Awesome - lets get started on creating our custom animation!

We start out by creating an animation controller that takes a duration and an operation (e.g a .push or .pop operation).

class CustomTransitionAnimation: NSObject {
 fileprivate let operationType: UINavigationControllerOperation
 fileprivate let positioningDuration: TimeInterval
 fileprivate let resizingDuration: TimeInterval

 init(
 operation: UINavigationControllerOperation,
 positioningDuration: TimeInterval,
 resizingDuration: TimeInterval
 ) {
 self.operationType = operation
 self.positioningDuration = positioningDuration
 self.resizingDuration = resizingDuration
 }
}

extension CustomTransitionAnimation: UIViewControllerAnimatedTransitioning {
 func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
 return max(self.resizingDuration, self.positioningDuration)
 }

 func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
 if self.operationType == .push {
 self.presentTransition(transitionContext)
 } else if self.operationType == .pop {
 self.dismissTransition(transitionContext)
 }
 }
}

extension CustomTransitionAnimation {
 // Custom push animations
 internal func presentTransition(_ transitionContext: UIViewControllerContextTransitioning) {

 }

 // Custom pop animations
 internal func dismissTransition(_ transitionContext: UIViewControllerContextTransitioning) {

 }
}

You may have noticed that I have two variables relating to duration. Why do I have this? Well if you look at the original animation again, you might be able to tell that the card grows/shrinks faster than the card is being positioned. (I had to slow the animation down quite a bit before I noticed this myself.) Therefore, we need two durations to animate across! So for the first delegate method func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval, I just return the greater of the two durations as the total transition duration.

In the second delegate method func animateTransition(using transitionContext: UIViewControllerContextTransitioning), I call two separate functions depending on the operation.

Before we jump into the next section where I describe how to animate our transitions, we need to tell our navigation controller to use our new animation controller. We do this by editing our NavigationController to conform to the UINavigationControllerDelegate and then provide our CustomTransitionAnimation as shown below.

class NavigationController: UINavigationController {
 override func viewDidLoad() {
 super.viewDidLoad()

 self.delegate = self
 }
}

extension NavigationController: UINavigationControllerDelegate {
 func navigationController(
 _ navigationController: UINavigationController,
 animationControllerFor operation: UINavigationControllerOperation,
 from fromVC: UIViewController,
 to toVC: UIViewController
 ) -> UIViewControllerAnimatedTransitioning? {
 return CustomTransitionAnimation(operation: operation, positioningDuration: 1, resizingDuration: 0.5)
 }
}

Now that our navigation controller is configured to use our animation controller, let's dive into the .push transition animation first.

Push Animation

Let's take a look at the following code snippet.

/// Perform custom presentation and dismiss animations
extension CustomTransitionAnimationController {
 internal func presentTransition(_ transitionContext: UIViewControllerContextTransitioning) {
 let container = transitionContext.containerView

 // Views we are animating FROM
 guard
 let fromVC = transitionContext.viewController(forKey: .from) as? Animatable,
 let fromContainer = fromVC.containerView,
 let fromChild = fromVC.childView
 else {
 return
 }

 // Views we are animating TO
 guard
 let toVC = transitionContext.viewController(forKey: .to) as? Animatable,
 let toView = transitionContext.view(forKey: .to)
 else {
 return
 }

 // Preserve the original frame of the toView
 let originalFrame = toView.frame

 container.addSubview(toView)
 }
}

To craft a custom transition animation, you start with transitionContext.containerView which serves as a hypothetical blank canvas. You then add views as subviews to the container that you want to animate. To reiterate in another way, the container holds everything. If a view wasn’t added to the container, it won’t show up in the animation.

Apple describes the containerView like this

The container view acts as the superview of all other views (including those of the presenting and presented view controllers) during the animation sequence. UIKit sets this view for you and automatically adds the view of the presenting view controller to it. The animator object is responsible for adding the view of the presented view controller, and the animator object or presentation controller must use this view as the container for all other views involved in the transition.

What Apple is saying here is that the view of the “presenting view controller” (our Card List View) will automatically be added to our blank canvas. It is our responsibility however to add any views from the “presented view controller” (our Detail View) to the canvas that we want animated.

So now that we have our canvas (ahem container) which contains the view of our Card List View UIViewController, we just need to add the view of our Detail View UIViewController. As demonstrated above, we can easily get this view (toView) from the transitionContext and add it to the container.

Ta-da! With the exception of calling transitionContext.completeTransition(), we’ve now made a custom transition animation! Granted, there is nothing special about our transition in the slightest. In fact there isn’t even an animation, but if we ran our app and tapped on a Card Cell, we would instantly transition to the Detail View.

To add our animation, we’ve got a bit more work to do. The end goal is to have the Card Cell appear to grow until it is full screen. So we still have to make it so that our Detail View looks like the Card Cell, is placed directly on top of the Card Cell, and then grows back into its original full screen size.

Animation Delegate

As you may have noticed in the above code snippet, I’ve cast the fromVC and toVC as Animatable. Animatable is a protocol I created to help manage which views and properties should be animated. In our case for example, we can get the toVC (Detail View) and fromVC (Card List View) from the transitionContext, but we don’t have a way to get the Card Cell from within our UICollectionViewController.

Most often I see examples of people casting the fromVC and toVC to their specific UIViewController subclass to solve this problem. But I find that this both clutters the transition animation code with property animations and restricts the transition animation controller to only work between those specific UIViewController subclasses. Using a protocol like the one I created below allows us more flexibility to use this animation between any two UIViewControllers that conform to Animatable.

protocol Animatable {
 var containerView: UIView? { get }
 var childView: UIView? { get }
}

So now we just have to update our CollectionViewController and DetailViewController to conform to our protocol.

CollectionViewController To keep track of the selected Card Cell, add a new property to the CollectionViewController and update it in the collectionView(didSelectItemAt:) delegate method.

fileprivate var selectedCell: UICollectionViewCell?

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
 self.selectedCell = self.collectionView.cellForItem(at: indexPath)
 ...
}

extension CollectionViewController: Animatable {
 var containerView: UIView? {
 return self.collectionView
 }

 var childView: UIView? {
 return self.selectedCell
 }
}

DetailViewController

extension DetailViewController: Animatable {
 var containerView: UIView? {
 return self.view
 }

 var childView: UIView? {
 return self.commonView
 }
}

Defining Start and End positions

Returning back to our CustomTransitionAnimation controller, we next need to calculate the start and end positions that our Detail View will animate across.

// Get the coordinates of the view inside the container
let originFrame = CGRect(
 origin: fromContainer.convert(fromChild.frame.origin, to: container),
 size: fromChild.frame.size
)
let destinationFrame = toView.frame

toView.frame = originFrame
toView.layoutIfNeeded()

fromChild.isHidden = true

The start position, although seemly straight forward, has one caveat. You’d think we’d be able to take the frame of our Card Cell and set that as the start position for our Detail View. We cannot do this however because the frame position of the Card Cell is relative to its position within the UICollectionView. So as shown above, we create the originFrame by converting the Card Cell’s frame to a coordinate system within our container view.

Our destination position thankfully is easy - its just the frame of the view we are going to.

Next we update our Detail View (toView) to have the new start position frame and hide the Card Cell (fromChild).

Performing the Animation

Now that we know where we are animating from (originFrame) and where we need to end up (destinationFrame), we can calculate the the change in both X and Y positions so that our two animators know how far in each direction to animate.

let yDiff = destinationFrame.origin.y - originFrame.origin.y
let xDiff = destinationFrame.origin.x - originFrame.origin.x

With this, we are ready to animate our view in the vertical direction…

let positionAnimator = UIViewPropertyAnimator(duration: self.positioningDuration, dampingRatio: 0.7)
positionAnimator.addAnimations {
 // Move the view in the Y direction
 toView.transform = CGAffineTransform(translationX: 0, y: yDiff)
}

…and in the horizontal direction.

let sizeAnimator = UIViewPropertyAnimator(duration: self.resizingDuration, curve: .easeInOut)
sizeAnimator.addAnimations {
 // Animate the size of the Detail View
 toView.frame.size = destinationFrame.size
 toView.layoutIfNeeded()

 // Move the view in the X direction. We concatenate here because we do not want to overwrite our
 // previous transformation
 toView.transform = toView.transform.concatenating(CGAffineTransform(translationX: xDiff, y: 0))
}

Since we need to animate the size independently of the vertical positioning, we cannot use the frame to animate both without overwriting the other. This is why in the above code snippets, I move the Detail View in the X and Y directions by using transforms and change the size by animating the frame.

Now that our view will resize and move to the proper location, we need a way to update properties of the Detail View, such as animating the corner radius, shadow, and close button. As I mentioned before, most often I see examples of transition animators updating these types of properties right here in the animation controller. But since the transition controller is simply responsible for the transition between two views, I think it makes more sense for the actual views to handle their own property animations.

To do this, we are going to update our Animatable protocol to include two optional functions and then call the corresponding function within our transition controller.

protocol Animatable {
 ...

 func presentingView(
 sizeAnimator: UIViewPropertyAnimator,
 positionAnimator: UIViewPropertyAnimator,
 fromFrame: CGRect,
 toFrame: CGRect
 )

 func dismissingView(
 sizeAnimator: UIViewPropertyAnimator,
 positionAnimator: UIViewPropertyAnimator,
 fromFrame: CGRect,
 toFrame: CGRect
 )
}

/// Default implementations
extension Animatable {
 func presentingView(
 sizeAnimator: UIViewPropertyAnimator,
 positionAnimator: UIViewPropertyAnimator,
 fromFrame: CGRect,
 toFrame: CGRect
 ) {}

 func dismissingView(
 sizeAnimator: UIViewPropertyAnimator,
 positionAnimator: UIViewPropertyAnimator,
 fromFrame: CGRect,
 toFrame: CGRect
 ) {}
}

Then back in the CustomTransitionAnimation controller, we can call the new function.

toVC.presentingView(
 sizeAnimator: sizeAnimator,
 positionAnimator: positionAnimator,
 fromFrame: originFrame,
 toFrame: destinationFrame
)

By implementing these functions in the Detail View, we know exactly when to animate a specific property depending on whether the screen is being presented or dismissed. In the next section, I’ll go over exactly how we will do this, but for the sake of finishing the push transition I am just going to move on for now.

The last thing we need to do is return the altered views back to their original state…

let completionHandler: (UIViewAnimatingPosition) -> Void = { _ in
 toView.transform = .identity
 toView.frame = originalFrame

 toView.layoutIfNeeded()

 fromChild.isHidden = false

 transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}

// Put the completion handler on the longest lasting animator
if (self.positioningDuration > self.resizingDuration) {
 positionAnimator.addCompletion(completionHandler)
} else {
 sizeAnimator.addCompletion(completionHandler)
}

…and start the two animations!

positionAnimator.startAnimation()
sizeAnimator.startAnimation()

Once the animations complete, our completionHandler block will get called, which will then designate the end of the transition by calling transitionContext.completeTransition(!transitionContext.transitionWasCancelled). This completes the push animation!

Animating UI Properties

If you were to run the project at this point, the card would animate to full screen. However, properties such as the card corner radius, shadow, and close button would not animate alongside the transition animation. This is where our presentingView and dismissingView delegates from earlier come into play.

Inside the DetailViewController, we implement these two delegates starting with presentingView.

func presentingView(
 sizeAnimator: UIViewPropertyAnimator,
 positionAnimator: UIViewPropertyAnimator,
 fromFrame: CGRect,
 toFrame: CGRect
) {
 // Make the common view the same size as the initial frame
 self.heightConstraint.constant = fromFrame.height

 // Show the close button
 self.closeButton.alpha = 1

 // Make the view look like a card
 self.asCard(true)

 // Redraw the view to update the previous changes
 self.view.layoutIfNeeded()

 // Animate the common view to a height of 500 points
 self.heightConstraint.constant = 500
 sizeAnimator.addAnimations {
 self.view.layoutIfNeeded()
 }

 // Animate the view to not look like a card
 positionAnimator.addAnimations {
 self.asCard(false)
 }
}

Using the sizeAnimator and positionAnimator, we can animate different parts of our view alongside the two independent property animators. Presenting our view now transitions smoothly from a Card Cell to a full screen detail view! In the next section we will talk about what it will take to dismiss the Detail View back to the Card Cell.

Pop Animation

Back in the TransitionAnimator, we need to implement the dismissTransition internal function. This function is much like its presentTransition counterpart as can be seen from the below code snippet.

internal func dismissTransition(_ transitionContext: UIViewControllerContextTransitioning) {
 let container = transitionContext.containerView

 // ===========================================================
 // Step 1: Get the views we are animating
 // ===========================================================

 // Views we are animating FROM
 guard
 let fromVC = transitionContext.viewController(forKey: .from) as? Animatable,
 let fromView = transitionContext.view(forKey: .from)
 else {
 return
 }

 // Views we are animating TO
 guard
 let toVC = transitionContext.viewController(forKey: .to) as? Animatable,
 let toView = transitionContext.view(forKey: .to),
 let toContainer = toVC.containerView,
 let toChild = toVC.childView
 else {
 return
 }

 container.addSubview(toView)
 container.addSubview(fromView)

 // ===========================================================
 // Step 2: Determine start and end points for animation
 // ===========================================================

 // Get the coordinates of the view inside the container
 let originFrame = fromView.frame
 let destinationFrame = CGRect(
 origin: toContainer.convert(toChild.frame.origin, to: container),
 size: toChild.frame.size
 )

 toChild.isHidden = true

 // ===========================================================
 // Step 3: Perform the animation
 // ===========================================================

 let yDiff = destinationFrame.origin.y - originFrame.origin.y
 let xDiff = destinationFrame.origin.x - originFrame.origin.x

 // For the duration of the animation, we are moving the frame. Therefore we have a separate animator
 // to just control the Y positioning of the views. We will also use this animator to determine when
 // all of our animations are done.
 let positionAnimator = UIViewPropertyAnimator(duration: self.positioningDuration, dampingRatio: 0.7)
 positionAnimator.addAnimations {
 // Move the view in the Y direction
 fromView.transform = CGAffineTransform(translationX: 0, y: yDiff)
 }

 let sizeAnimator = UIViewPropertyAnimator(duration: self.resizingDuration, curve: .easeInOut)
 sizeAnimator.addAnimations {
 fromView.frame.size = destinationFrame.size
 fromView.layoutIfNeeded()

 // Move the view in the X direction. We concatinate here because we do not want to overwrite our
 // previous transformation
 fromView.transform = fromView.transform.concatenating(CGAffineTransform(translationX: xDiff, y: 0))
 }

 // Call the animation delegate
 fromVC.dismissingView(
 sizeAnimator: sizeAnimator,
 positionAnimator: positionAnimator,
 fromFrame: originFrame,
 toFrame: destinationFrame
 )

 // Animation completion.
 let completionHandler: (UIViewAnimatingPosition) -> Void = { _ in
 fromView.removeFromSuperview()
 toChild.isHidden = false

 transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
 }

 // Put the completion handler on the longest lasting animator
 if (self.positioningDuration > self.resizingDuration) {
 positionAnimator.addCompletion(completionHandler)
 } else {
 sizeAnimator.addCompletion(completionHandler)
 }

 // Kick off the two animations
 positionAnimator.startAnimation()
 sizeAnimator.startAnimation()
}

The biggest difference is that instead of calling the presentingView delegate, we call the dismissingView delegate method. With the dismiss transition complete, we go back into the DetailViewController to implement the dismissingView delegate method.

func dismissingView(
 sizeAnimator: UIViewPropertyAnimator,
 positionAnimator: UIViewPropertyAnimator,
 fromFrame: CGRect,
 toFrame: CGRect
) {
 // If the user has scrolled down in the content, force the common view to go to the top of the screen.
 self.topConstraint.isActive = true

 // If the top card is completely off screen, we move it to be JUST off screen.
 // This makes for a cleaner looking animation.
 if scrollView.contentOffset.y > commonView.frame.height {
 self.topConstraint.constant = -commonView.frame.height
 self.view.layoutIfNeeded()

 // Still want to animate the common view getting pinned to the top of the view
 self.topConstraint.constant = 0
 }

 // Animate the height of the common view to be the same size as the TO frame.
 // Also animate hiding the close button
 self.heightConstraint.constant = toFrame.height
 sizeAnimator.addAnimations {
 self.closeButton.alpha = 0
 self.view.layoutIfNeeded()
 }

 // Animate the view to look like a card
 positionAnimator.addAnimations {
 self.asCard(true)
 }
}

Here again we are animating properties of the view to go from looking like the Detail View to looking like the Card Cell. The one tricky thing going on here is the animation to move the common view to the top of the screen if the user happens to scroll the common view off screen while viewing the body of the Detail View. Essentially, if the user happened to scroll all the way to the bottom of the scroll view and then tapped the close button, I want the common view to immediately start appearing from the top of the view while transitioning to look like the Card Cell.

And with that, we’ve completed our very own custom transition animation! Stand back and look and what we’ve accomplished.

Conclusion

To summarize the creation of a custom transition animation, I recommend following this general approach.

  1. Create your views to transition between (obviously)
  2. Create an animation protocol for your views to conform to
  3. Create a transition controller and use your protocol to reference specific properties of your view
  4. Execute the animation

If you followed this entire blog (or just skipped to the end), you should have a collection view that behaves much like the App Store Today view. Check out the final project here. This however is just an example of what can be done with custom transition animations. You are only limited by your imagination!

John DeLong
John DeLong
Development Practice Co-Lead

Looking for more like this?

Sign up for our monthly newsletter to receive helpful articles, case studies, and stories from our team.

How to Prepare for our Associate Software Developer Position
Team

How to Prepare for our Associate Software Developer Position

June 30, 2023

Tips for applying to MichiganLab's Associate Software Developer program

Read more
Lessons Learned from our Associate Developer
Team

Lessons Learned from our Associate Developer

September 13, 2023

One of our Associate Software Developers, Rohit, reflects on his time at MichiganLabs working on a short-term project, what he learned about real-world development, and the software consultancy business model.

Read more
User research: The heartbeat of successful development
Business Design Process

User research: The heartbeat of successful development

July 15, 2024

User research in software development is essential for success. Learn how consistently engaging in methods like user interviews, usability testing, and field studies throughout the product lifecycle, helps ensure your solutions align closely with user needs.

Read more
View more articles