How to add basic drawer menu with Swift 5

Using UIViewControllerTransitioningDelegate

Arty Korzh
9 min readSep 26, 2020

Similar tasks are often faced by iOS developers, especially if they work on outsourcing projects or as a freelancer. There are a bunch of solutions across the internet and I’m going to introduce the solution that I have been used already in multiple projects. So let’s dive in.

Start with an empty project. For demo purposes, I’m gonna use the Main storyboard as a start point. Then add navigation bar with left navigation bar button to our ViewController. I’ve chosen system image named “text.justify” as it almost looks like burger menu icon

Then add related @IBAction to this bar button menu in ViewController.swift. You will finish with something like this

@IBAction func didTapMenu(_ sender: Any) {        
}

But what we gonna do in this action? My solution is just to present a new controller. Yeah, right now it’s not looking like what we need, but have a little patience

Let’s add another controller called DrawerMenuViewController. But first let’s add little bit of structure into our code. Let’s add folder called DrawerMenu and add one nested folder called DrawerMenuController. Finally create new ViewController by pressing Ctrl+N and select Cocoa Touch Class. I prefer to use separate xib files for each new ViewController so i’ve checked Also create XIB file

Now your project should look like this

We ain’t gonna stop on the content of this controller. I’ve just changed the background color so we can see how our drawer controller is shown in the app

Let’s back to IBAction that we’ve created earlier. Add code to present new controller.

// ViewController.swift@IBAction func didTapMenu(_ sender: Any) {        
let drawerController = DrawerMenuViewController()
present(drawerController, animated: true)
}

Since I use XIB file for this controller, I can simply instantiate it with the base init function. And this is all the code we need in this class.

If we run the app now and press the menu button we will see basic presenting animation from bottom to top with squashing underlying view. This is definitely not what we need to achieve. So let’s go deeper

Create a new folder under DrawerMenu called DrawerTransitionManager. Here will be stored all the files responsible for displaying our drawer in the correct manner. Next, create a file called DrawerTransitionManager in this folder choosing Swift File as a source.

// DrawerTransitionManager.swiftimport UIKit class DrawerTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
}

UIViewControllerTransitioningDelegate class responsible for presenting and animating controllers presented by others

First, we need to provide this class as transitioningDelegate to presenting controller, in our case DrawerMenuViewController. Open DrawerMenuViewController.swift and create new variable transitionManager that contains DrawerTransitionManager. It’s important to store manager as a variable because transitionDelegate is a weak variable and will be unassigned until your controller will be dismissed, and it will cause problems with animation later.

Next, create custom init function where we will customise the behaviour of the newly presented controller. It’s required to call super.init(nibName: nil, bundle: nil) inside this custom init. After that add few lines of code to change modalPresentationStyle and assign new transitioningDelegate

// DrawerMenuViewController.swift// store transition manager inside view controller class
let transitionManager = DrawerTransitionManager()
init() {
super.init(nibName: nil, bundle: nil)

// it's important to set presentation style to custom
// because it allows us to modify the presentation later on
modalPresentationStyle = .custom
transitioningDelegate = transitionManager
}
// since we provide custom initialiser
// we have to provide required init function
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

But this is not enough to create drawer animation as we expect it to be. Return to DrawerTransitionManager and start writing first function. XCode autocomplete will help you

// DrawerTransitionManager.swiftfunc presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController?

It requires us to create this presentation controller. Create new file in the same folder named DrawerPresentationController by selecting Cocoa Touch Class and set it as subclass of UIPresentationController

// DrawerPresentationController.swiftimport UIKit class DrawerPresentationController: UIPresentationController {// define default size of child controller 
// inserted in this container controller
override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
// I want to have some space to the right of drawer
// to see underlying controller
return CGSize(width: parentSize.width * 0.8, height: parentSize.height)
}
// define final frame of presented controller
// will be set at the end of animation
override var frameOfPresentedViewInContainerView: CGRect {
var frame: CGRect = .zero
guard let containerView = containerView else {
return frame
}
frame.size = size(forChildContentContainer: presentedViewController, withParentContainerSize: containerView.bounds.size)
return frame
}
// set final frame to controller at the begin of layouting
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
}

Now we can return our presentation controller in manager class

// DrawerTransitionManager.swiftfunc presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {        
return DrawerPresentationController(presentedViewController: presented, presenting: presenting)
}

If we run the app now and press the menu button we will still see the wrong animation. But our drawer controller will take only 80% of screen width.

Let’s create the correct animation. In our manager, we also have two functions responsible for animation. One for presenting and one for dismissing. You can set different animation for those processes. But we will create the same animation but in different directions.

// DrawerTransitionManager.swiftfunc animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
}

As you can see we need to return something conforms UIViewControllerAnimatedTransitioning. First, create a new file at the same folder called DrawerSlideAnimation, it is also a simple Swift file.

// DrawerSlideAnimation.swiftimport UIKit class DrawerSlideAnimation: NSObject, UIViewControllerAnimatedTransitioning {
}

It requires two functions: one for duration of animation and one directly for animation

// DrawerSlideAnimation.swiftfunc transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {        
// let's decide drawer will be sliding during half of a second
return 0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// magic happens here
}

Also to define direction add new public variable to this class, that will be changed depends on the process.

// DrawerSlideAnimation.swiftvar isPresenting: Bool = true

Let’s create our slide animation.

// DrawerSlideAnimation.swiftfunc animateTransition(using transitionContext: UIViewControllerContextTransitioning) {         
// if presenting drawer will be under key .to
// if dismissing drawer will be under key .from
let key: UITransitionContextViewControllerKey = isPresenting ? .to : .from
// check if our controller exist in transition
guard let presentedController = transitionContext.viewController(forKey: key) else {
return
}
// for convenience set containerView to new variable
let containerView = transitionContext.containerView
// define position and size of drawer at the end of presentation
let presentedFrame = transitionContext.finalFrame(for: presentedController)
// define position and size of drawer at the end of dismiss
// just offset presentedFrame by its width to left
let dismissedFrame = presentedFrame.offsetBy(dx: -presentedFrame.width, dy: 0)
// if we are going to present controller, we need to add its view to the container
if isPresenting {
containerView.addSubview(presentedController.view)
}
// get actual duration for animation, defined in the function above
let duration = transitionDuration(using: transitionContext)
// we need to notify that transition is finished with status from the context, not our custom animation
let wasCancelled = transitionContext.transitionWasCancelled
// define start and end frames for animation
let fromFrame = isPresenting ? dismissedFrame : presentedFrame
let toFrame = isPresenting ? presentedFrame : dismissedFrame
// set start frame for controller view before animation
presentedController.view.frame = fromFrame
UIView.animate(withDuration: duration) {
// change controller view frame to end frame during animation
presentedController.view.frame = toFrame
} completion: { (_) in
// notify animation complete with context status
transitionContext.completeTransition(!wasCancelled)
}
}

That was the hardest and most important part. Now we need to provide this animation to our manager. Since the animation class is the same for both processes I prefer to put it in the variable and change only direction defining variable.

// DrawerTransitionManager.swiftlet slideAnimation = DrawerSlideAnimation()func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {        
slideAnimation.isPresenting = true
return slideAnimation }
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
slideAnimation.isPresenting = false
return slideAnimation
}

That’s it. Now you can run the app and see that drawer moving from left to right as it supposed to do.

Your final structure should look like this.

But there are a few problems left. We have a transparent space to the right of the drawer and we have no opportunity to close this drawer. Let’s fix this.

In the presentation controller create semitransparent dimming view, that will cover underlying view.

// DrawerPresentationController.swiftprivate lazy var dimmingView: UIView = {        
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
view.translatesAutoresizingMaskIntoConstraints = false
view.alpha = 0
return view
}()

I prefer lazy initializers for code base views. It’s just my preference. You can use an optional variable and instantiate it before displaying, it’s up to you.

Now we can use two methods to show and hide our dimming view depends on the transaction process.

// DrawerPresentationController.swiftoverride func presentationTransitionWillBegin() {      
guard let containerView = containerView else {
return
}
// before presentation
// add dimmingView to container view beneath presentedController
containerView.insertSubview(dimmingView, at: 0)
// set constraints of dimming view to fit container
// that's why we set translatesAutoresizingMaskIntoConstraints to false
NSLayoutConstraint.activate([
dimmingView.topAnchor.constraint(equalTo: containerView.topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
dimmingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
])
// check for animation coordinator
// if it's missing make dimming view visible immediately
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 1
return
}
// otherwise do it in animation
// duration of this animation same as duration of slide animation
coordinator.animate { (_) in
self.dimmingView.alpha = 1
}
}
override func dismissalTransitionWillBegin() { // do the same for making dimming view transparent on dismiss
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 0
return
}

coordinator.animate { (_) in
self.dimmingView.alpha = 0
}
}

Now you will see the semitransparent dark background, that fade in and fade out alongside sliding animation. But we still can’t close the drawer. The solution is to add UITapGestureRecognizer to the dimming view and on tap hide drawer.

// DrawerPresentationController.swiftprivate lazy var dimmingView: UIView = {        
let view = UIView()
/// ...
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissPresentedController))
view.addGestureRecognizer(tapRecognizer)

return view
}()
@objc private func dismissPresentedController() {
presentedViewController.dismiss(animated: true)
}

And that’s all for the most basic drawer for your apps. You can add layout to the drawer controller the same as always, it will be correctly resized to 80% of width in this case according to autolayout rules.

Final result

Full code available on GitHab

Where to go from here?

  • You can create a delegate protocol for the drawer to communicate between ViewController and DrawerController for proper navigation.
  • You can change left-side drawer to the right-side by changing dismissedFrame offset by positive width value and moving the origin of frameOfPresentedViewInContainerView to 0.2 of screen width on X axis
  • You can improve animation inside DrawerSlideAnimation with easing, by adding and animating other views that make your app stun user with wow-effect
  • You can use this knowledge about UIPresentingController and UIViewControllerAnimatedTransitioning to create your own presentable controllers with custom animation

Thanks for reading. Fell free to leave comments, I appreciate any feedback

--

--