Custom UIViewController transitions in Swift

Tung Fam
17 min readFeb 27, 2020

--

Intro

This is a tutorial on how to implement a custom transition between two ViewControllers, animating a CollectionViewCell into an ImageView of a ViewController. Below you may see how it looks like. Or check the HD videos here with better quality and precision.

Note: For presentation purpose the animation duration is a lot longer than should be on production.

Few notes before we start

This tutorial was written with an assumption that the reader may not have any prior experience with Custom Transitions. You should be familiar with Swift development. Just to set the right expectations, it is relatively hard to implement Custom Transitions. So in case, you find issues implementing it in your own project, be patient and try hard :) If you will follow the tutorial step-by-step, it should be fairly smooth. And after that, you may try to implement it into your own project.

The inspiration comes from the Airbnb iOS app. Basically I was trying to make the same animation as they have on the opening of Experience screen (in Explore tab) and expanding the CollectionViewCell into the ImageView of the ViewController.

To keep the focus on the main topic, we won’t be implementing the CollectionView and ViewControllers.

In case you want to see the code before reading the tutorial, it’s here.

Be attentive to presenting and dismissing animations explanations, because they may differ.

The presenting (parent) ViewController will be called “FirstVC”. The presented (or dismissed) ViewController will be called “SecondVC”.

Some theory 🤓

It’s important to understand how the transition actually works. So imagine you have 2 views: 1st view of FirstVC and 2nd view of SecondVC. To transition between them you provide the system with a duration of the transition, for example, 1 second. During this time there will be a new view (provided by the system) called Transition Container View. So this view exists for 1 second and will contain the transition animation between 1st view and the 2nd view. We will be adding image, label, icons, etc on this Transition Container View and animate as you would normally animate the views in your app (for example using UIView.animate).

To animate the label (or any other element) we will need its starting position (frame) and a final position. Also, we will need to set proper alpha values to make the animation smoother.

Let’s code 👩‍💻👨‍💻

Block 0: Setup

First, download the starter project. Open this URL and press Download button. Unzip it, open the .xcodeproj file.

In case you’d like to run it on real device, change the Dev team and Bundle ID in project settings. Or simply use the simulator.

Run the app. You will see a simple UICollectionViewController with locations around the world. When you tap on the cell or dismiss the screen it uses the default presentation.

Now take a look at the project structure and files it already has.
The files you will be working with are: FirstViewController, FirstViewController+CollectionView, FirstViewController+TransitioningDelegate and Animator.
I separated some extensions into the separate files just for a better understanding and to make it easier to write this tutorial and avoid confusions.

From now on, step-by-step we will start to implement the custom transition like on the topmost video of this tutorial.

Block 1: Preparing UIViewControllerTransitioningDelegate

We won’t make any animations yet, just conforming to required protocols for custom transitions.

Open FirstViewController+TransitioningDelegate.swift file
Insert this code:

  1. We conformed to UIViewControllerTransitioningDelegate so that our FirstVC becomes a delegate that will implement custom transition methods.
  2. Custom transition method for presenting. It should return an object that would animate the transition. For now, it returns nil, which means that it will use the default transition.
  3. The same as bullet 2, but for dismissing.

Open FirstViewController.swift the file and find presentSecondViewController(with:) method. After declaring secondViewController add this line of code:

4. Making FirstVC to be the delegate that conforms to UIViewControllerTransitioningDelegate that we just implemented in the steps 1–3.

Run the project. It should build and the app should work the same as before.

Block 2: Creating Animator class and preparing for the first transition (without animation).

We will implement the Animator class which is the class responsible for animation and will hold all the animations code. We will mostly work with this class in this tutorial.

Open FirstViewController.swift file and insert:

5. Declare these 2 properties in the scope of FirstVC:
- selectedCell is a cell that was selected (tapped)
- selectedCellImageViewSnapshot is a snapshot of the image view of selected cell.

It’s important to understand what the snapshot is. Shortly, it’s a view that has a current rendered appearance of a view. Think of it as you would take a screenshot of your screen, but it will be one single view without any subviews.

Now, open FirstViewController+CollectionView.swift

Find method collectionView(_:didSelectItemAt:). Before calling presentSecondViewController, insert these 2 lines:

6. Assigning selected cell to the property created in bullet 5

7. Assigning snapshot to the property created in bullet 5

Open Animator.swift. Insert this big chunk of code:

8. Animator is a class that will implement the animation. It conforms to UIViewControllerAnimatedTransitioning which is the same type that needs to be passed in the functions in FirstViewController+TransitioningDelegate.swift the file (where we returned nil in Block 1). So the instance of this class will be responsible for either presentation or dismissal animation.

9. These are the properties that will be needed for animation. The names should be clear to describe themselves.

10. Custom initializer that assigns all the properties we declared in bullet 9.
Important note: if something “goes wrong”, for example, you can’t prepare all the needed properties (basically the init fails), make sure to return nil. This way the app will use default present/dismiss animation and the user won’t be stuck somewhere in the middle of the transition.

11. Getting the Frame of the Image View of the Cell relative to the window’s frame. This is a very essential step since we will need to animate in the Transition Container View, we need to convert the cell from the collection view’s to an appropriate coordinate system.

12. Required method of UIViewControllerAnimatedTransitioning protocol. We just return the animation duration we want.
Note that we use a stored property because it will be reused later. So that we don’t have different values flying around which prevents hunting the bugs 🐛 :)

13. A required method of UIViewControllerAnimatedTransitioning the protocol. All the transition logic and animations will be done here. Leave it empty for now.

14. Simple enum that defines if the screen is dismissed or presented. Will be used to pass to Animator to define which animation to use.

Then open FirstViewController.swift. Insert this one line:

15. Define optional animator instance.

Then open FirstViewController+TransitioningDelegate.swift. Replace the code in these 2 methods that we had before:

16. We are preparing the properties to initialize an instance of Animator. If it fails, return nil to use default animation. Then assign it to the animator instance that we just created.

17. Same as 16 but for dismissal.

Next, open Animator.swift the file. Going back to the method animateTransition(using:) that we left in step 13. Insert this code:

18. containerView (Transition Container View that I mentioned in the very start of this tutorial) is an instance provided by the transition. Imagine this container as a view that you would use to run your animations on. Basically it’s a view that is pasted in between 1st and 2nd VCs to show the animation.

19. In order to present the 2nd screen, we need to add its subview to containerView. If it fails we completeTransition with false which means the transition won’t happen

20. Calling completeTransition , in the end, allows the transitionContext know that transition finished.

Build and run. Observe that the transition is immediate and there are no animations (even no default animations). So this is a starting point of our animation. Yes, it was a long block, but the animation is coming in the next one!

Result of block 2 (no transition)

Just for better understanding and curiosity you may do these things to better learn how it works:

  1. Go to animateTransition(using:) method and try to comment out the addSubview or completeTransition calls. So that you understand what this code is for.
  2. Also, you may try to go to Animator’s init and return nil as a first thing in a method. So that you also understand what happens in case init fails. User won’t be stuck in the broken animation but rather have a default animation.

Block 3: Making the first animation for transition.

We will implement the first animation which is the transition of the image from the cell into the image of a SecondVC.

Open Animator.swift file and delete the line we added on step 20:

transitionContext.completeTransition(true)

Then insert this code:

21. selectedCell and window are unwrapped to make sure they aren’t nil. We are assigning the window of the screen that is currently presented. Meaning if it’s presentation, then it will be a window of FirstVC, if it’s dismissal then it’s the window of SecondVC.
cellImageSnapshot — snapshot of the image of selected cell
controllerImageSnapshot — snapshot of the image of the 2nd VC.

22. imageViewSnapshot is a view that will be used to animate the transition of the image.
If we are presenting, then we will use the cell’s image for animation.
If we are dismissing, then we will use the VC’s image for animation.

23. The presented view should be transparent, otherwise, it will overlap our animation view.

24. Adding the imageViewSnapshot — the first view that we will animate in the container view.
You may wonder why it’s an array before forEach, later we will add more views into that array 😉.

25. Getting the frame of the ImageView in the SecondVC. So that we can use it to transition from the cell’s frame.

26. Assigning the initial frame to the snapshot that will be animated. If we don’t do that it, will end up in the top left corner since we just added this new view to the view hierarchy. It’s important to understand that the snapshot is completely a new view, but not an image of the cell or an image of the SecondVC.

27. Animation (finally 🎉!). In case you haven’t worked with UIView.animateKeyframes before, please read it up before. But in general, it should be easy to understand.
You may wonder why don’t we just use UIView.animate, since we only have 1 animation. Answer: we will add more later with different start points and different durations.

28. For now the animation is pretty simple. If we are presenting, we are changing the frame of the imageViewSnapshot from cell’s frame to the frame of Image of SecondVC. If we are dismissing we are changing the frame to the cell’s image.

29. In completion we need to do the cleanup and remove all the views we used during transition. For now, just removing imageViewSnapshot.

30. You remember on step 23, we made the toView transparent. So now once the transition is finished we need to make it non-transparent.

31. Finally, complete the transition

Build and run. Run it on iPhone 11 or bigger, in order to see the issues that we are going to fix 😄.

Result of block 3 (image)

As you may see, it doesn’t look very good, right?
Let’s put aside the fact that there is no animations of background view, corner radius, title label, and the close button.
There are 2 problems:
1. Image is being stretched when presenting (compressed/squeezed when dismissing).
2. At the end of animation, you may see the immediate jump from one image to another.

It’s important to mention that both image views (in the cell and 2nd VC) are using contentMode = .scaleAspectFill. So that we have fully filled image in the given rectangle.

Why these 2 problems are there? It is because 2 image view frames have different proportions. Cell’s image is a square, controller’s image is a rectangle.

Just for curiosity and comparison, try running on iPhone SE. You will notice that the stretching/compression is not that noticeable. The reason is because the ratio of the image in SecondVC is almost a square.

If you are curious, try changing the height constraint of the imageView in 2nd VC to 320, and you will see the perfect transition without stretching/compression and jumping. But we can’t fix it this way, because first of all we have different phones and because we need to follow guidelines provided by our design team 🤓. Don’t forget to change the constraint back to previous value :)

So, now it’s clear what is the issue. The issue is the different ratio of the images which we use to transition.

How do we solve it? I had a couple of solutions in mind but in the end, I sneak peeked the solution of the Airbnb app.

As you may see, there is an image change with the fade. From that I realized that the solution is:

  1. We need to use 2 images during the transition
  2. Animate the frame of both images at the same time.
  3. When presenting we will need to fade out the cell’s image and fade in the controller’s image at the same time. When dismissing, it’s vice versa.

If the solution doesn’t sound very clear let’s jump to Block 4 and try to code, and hopefully it should be much more clear.

Block 4: Smooth transition between cell/controller images to avoid stretching/squeezing.

We will make a transition from the cell’s image to the controller’s image to be smooth, in order to avoid stretching and squeezing.

Open Animator.swift:

32. Change selectedCellImageViewSnapshot to be a var.

Delete (!) the code you added in step 22:

Yes, the code above should be deleted (!).

Instead of it, add this:

33. When presenting, assign selectedCellImageViewSnapshot = cellImageSnapshot. This is a workaround to the issue that at the moment of taking the selectedCellImageViewSnapshot snapshot, the view is not yet updated so we take the snapshot again. I couldn’t find the proper way to overcome this issue.

From now on, most of the changes we will do in this method: animateTransition(using:). Because it would be hard to write where to add which line of code, I will be pasting the whole method and you will need to only modify those pieces of code that are numbered. In case there are any issues, you can try to check this Github project with all the commits. Commits are done the same way as Blocks in this tutorial so you should be able to understand what was changed.

So modify animateTransition(using:) method this way:

34. Note: we deleted the code of step 24. So in step 34, we are adding 2 snapshots that will be animated. The cell’s image and controller’s image.

35. Same step as we did before in step 26, but now for these 2 new views. We are assigning the initial frame to both of the views.

36. and 37. If we are presenting, then the controller’s image should be invisible but the cell’s image should be fully visible. And vice versa for dismissing.

38. Note: we deleted the code of step 28. Add these 2 new lines which do exactly the same what we did in step 28, we are changing the frame of the images to their final frame state so that animation happens.

39. Animate alpha of both images at the same time. Here you could play around with the duration values, but I found the duration of 0.6 to be good for this UI.

39.1. I initially forgot to add this step, that’s why it has such a weird number :) So here we removed, what was in step 29, and then we remove the 2 subviews that we used for animation.

Run it. You will now see a smooth transition between 2 of the images. This way we can avoid jumping. Also, try running it with 0.25 animation duration (duration property in Animator class) and see that the user won’t even notice the “change” of the image and any problems with the animation unless they record a screen and watch it slowly (as we did 😜 with the Airbnb app).

Result of block 4 (smooth image)

So again, a short summary of what we did here to avoid the jump/stretching/squeezing issues. Try running the app with an animation duration of 3 seconds. So as you can see we are now animating 2 images, one image is the image of the cell, another of the controller. So, when presenting we fade out the cell’s image and at the same time we fade in the controller’s image. This way we substitute the “stretchy” image into the correct image that in the end will be presented in the controller, and since in the controller the image is perfect (without stretch) user won’t see any issues. The same about dismissing animation but vice versa :)

This block was the hardest :) Next blocks are relatively easy comparing to this part because they are quite similar and have a similar logic/concept.

Block 5: Adding a fading background.

In this block, we will be adding a background view on the back to make the animation nicer. It will be just a plain white view (or black for Dark mode).

40. Instantiate a background and fade views. What they will do is explained below.

41. If we are presenting, our background view will be just a white (or black in Dark mode) view. Because on the SecondVC we don’t have anything. In your project, it most likely will be different and as an example, you can see what we did it the else statement, which is for dismissing animation.
So for dismissal, the background is a snapshot of the collection view, and we add the fadeView on top of it. So that when we do the animation it will fade.

42. Add the backgroundView as a first thing in the array. It’s important that it’s first so that it is behind all the views in the Transition Container View.

43. Changing alpha of fade view for the fade animation.

44. Remove background from superview.

Build and run. Observe that the background is now animated and fades in/out with white color. Our animation is getting nicer, isn’t it ?:)

Result of block 5 (background)

Block 6: Animate transition of the label.

Next, we will be animating the label. It is quite similar to animating the image (but without workarounds 😬). The label will be moving and growing along with the image.

45. Declare a new cellLabelRect property.

46. Convert the frame of the label to the window and assign it to the recently declared property.

47. Get a snapshot of the cell’s label. This snapshot view will be used to animate the transition.

48. Add cellLabelSnapshot as a 4th element, so that the label is added to animation container.

49. Get the rect of the label in the SecondVC. This is needed to know to which position we should transition our label from the cell’s position. And vice versa for dismissal: we will need the initial position of the label on the SecondVC.

50. Assigning the starting position of the label that will be animated. If we are presenting then the starting position is the position of cell’s label rect, but if we are dismissing, it would be controller’s label rect.

51. So in the previous step, we did set a start position. And now we are setting the end position, and since it’s in animation block, it will animate the change of the frame. Similarly, we assign the controller’s label rect if we are presenting, otherwise the cell’s label rect.

52. As we usually do: remove the cell’s snapshot that we used to animate the transition.

Run the project. Observe how the label is being transitioned together with the image.

Result of block 6 (label)

Just for better understanding and curiosity, you may do these things to better learn how it works: comment out the step 50 that we did. Run the app and observe what happens. So basically we are setting the initial position of the label otherwise it ends up in the top left corner. Similar happens if you try to comment step 51. Try it and see what happens.

Block 7: Animate close button.

We are almost done! 2 things left. Corner radius and the close button. Let’s start from the close button. We will animate the appearance of the close button. It won’t change the position though, because it’s only present on the SecondVC.

53. Make a snapshot view of the close button. This view will be used for animation

54. Add closeButtonSnapshot to Transition Container View.

55. We only need to have the frame of the close button from SecondVC because we don’t have it on the FirstVC. So basically this is for anything that you have only on the 2nd screen. Here we just want it to fade in/out. You may decide to make it fancier, but the idea is always the same. You make a snapshot, set it a frame and animate it.

56. Assign the frame from the previous step to the animating close button view. Setting the initial alpha. For presenting it will start from 0 so that it can fade in. For dismissing it will start from 1 so that It can fade out.

57. Add a keyframe animation to fade in/out the close button. You may notice this code:isPresenting ? 0.7 : 0. This means when we present we want it to fade in during 0.3 sec at the very end of the whole animation so that we don’t see the close button too early because the image is not transitioned yet. It doesn’t look nice if we show a close button on a not yet visible screen. And for dismissing we fade it out at the very start of the animation.

58. As always :)

Result of block 7 (close button)

Block 8: Animate corners.

Finally, we are going to animate the corners. The cell has a corner radius applied, but the controller’s image doesn’t. So it will smoothly go from “cornered” into a “non-cornered” state.

59. Setting the initial corner radius: for presenting we set it the same value we have for the cell so that it will transition from the same state as it was in the cell. And for dismissing it will have radius 0 as it is in the controller.

60. Setting the end corner radius value so that it gets animated in the animation block.

Side note for good programming practices: it’s better to keep the constant value 12 in one variable (for example as a static constant). So that when one day the design changes you will change it in 1 place and won’t need to remember where you have to change it. I didn’t do to keep the tutorial simpler.

Result of block 9 (corners)

Great job! You are done 👏.

The final project can also be downloaded from here.
The project has commits that are exactly the same as the Blocks in this tutorial.

I will summarise in a few steps what we did:

  1. Created an Animator and conformed it to UIViewControllerAnimatedTransitioning and implement the required methods.
  2. Conformed the VC to UIViewControllerTransitioningDelegate and implement the required methods.
  3. Used Animator to implement the transition animation.
  4. Created a snapshot for each view to animate.
  5. Set the initial and end position (frame). Set the alpha. Here you could also make more things, basically as design/animation requires.
  6. Animate in the animation block.
  7. Remove animation views from the Container.

Some additional tips:

  1. To better see how animation works, increase the animation duration. Also, very useful to debug the issues.
  2. It matters at which point you make a snapshot of a view. Also, it matters if you pass a parameter afterScreenUpdates to be true or false. I’d recommend taking a snapshot in animateTransition(using:) method.
  3. The rects (frames) for the FirstVC can be taken in the Animator’s init. But rects for the SecondVC should be taken in animateTransition(using:) because, before that moment, SecondVC is not initialized yet.

And now we are officially done 😄. Good job 👍.

I tried to make all the steps as clear as possible (in case they are not please make sure to provide the feedback, I’ll appreciate it a lot 🙏). Feel free to leave a comment or reach me out for comments, questions or any feedback.

Clap 👏 👏 👏 if you found the tutorial useful and if it saved you some time 🕑. Follow me for more articles and tutorials in the future. Btw, you can clap more than once 😉.

❗️We, at tado°, are hiring iOS Developers 🚀. Are you up for a challenge?

Thanks to iOS devs from Peer Lab Kyiv iOS who helped to improve the tutorial.

--

--