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.
- Create your views to transition between (obviously)
- Create an animation protocol for your views to conform to
- Create a transition controller and use your protocol to reference specific properties of your view
- 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!
Looking for more like this?
Sign up for our monthly newsletter to receive helpful articles, case studies, and stories from our team.
UX Writing Tips
February 3, 2023Kai shares a few tips he's collected on how to write for user interfaces.
Read moreMichiganLabs’ approach to product strategy: Driving software success
February 12, 2024 Read moreHow our Associates are using AI tools: Advice for early-career developers
August 13, 2024Our 2024 Associates at Michigan Labs share their experiences using AI tools like GitHub Copilot and ChatGPT in software development. They discuss how these tools have enhanced their productivity, the challenges they've faced, and provide advice for using AI effectively.
Read more