iOS Tutorial: How to make a customizable interactive slide-out menu in Swift

Create an interactive slide-out menu using Custom View Controller Transitions

In iOS development, there’s a love-hate relationship with the slide-out menu.

One one hand, Apple discourages its use and offers no support in UIKit.

On the other hand, it’s a common feature that clients ask for.

This leaves us developers with the burden of building this UI component from scratch, or going with a 3rd party library.

In a previous tutorial, I wrote about building a slide-out menu by nesting Container Views inside a Scroll View. At first, this seemed like a clever solution. But this approach had a few weaknesses:

  • The Scroll View pan gesture conflicts with horizontal swiping in the main content area.
  • The main content area is tappable while the menu is open, so it needs to be masked by a modal.
  • It’s difficult to customize the slide-out animation.

Nowadays, I usually recommend using a Tab Bar Controller instead of a slide-out menu. But if I had to build a slide-out, I would use Custom View Controller Transitions. My guess is that a number of slide-out implementations already do this, but I can’t tell for sure.

It takes a while to get used to Custom View Controller Transitions. If you need a refresher on using this API, check out my earlier modal pull-down tutorial.

By the end of this tutorial, you’ll end up with an interactive slide-out menu. Here’s the spec:

  • The Menu button opens the slide-out.
  • Tapping the main content area (blue) closes it.
  • Panning from the left edge opens the slide-out interactively.
  • Panning the main content area closes the slide-out interactively.
  • Interactive gestures either complete or roll back, depending on how far you pan.

 

slideoutFinal

 

Here’s a table of contents if you want to jump straight to a particular section:

  1. Get Started
  2. Add some helpful files
  3. Create the Present Menu Animator
  4. Wire up the Present Menu Animator
  5. Fix the Close Button
  6. Create the Dismiss Menu Animator
  7. Wire up the Dismiss Menu Animator
  8. Add the Dismiss Interaction
  9. Add a Pan Gesture Recognizer
  10. Wire up the Dismiss Interaction
  11. Add the Present Interaction
  12. Add a Screen Edge Pan Gesture Recognizer
  13. Wire up the Present Interaction

1. Get Started

Download the starter project zip file. This comes from the Starter Project branch of the GitHub repo.

So far, it doesn’t look anything like a slide-out menu. It’s just a blue View Controller that opens a green modal. You can tap the buttons in the corners to open and close the modal.

 

starterProject

Here’s the code so far:

2. Add some helpful files

Before diving in, you first need to create two Swift files. If you’re in a hurry, you can just copy and paste them into your project, and then skip straight to section 3.

Otherwise, read on to see how they work.

A. Create the Interactor

The Interactor is like a state machine that tracks the status of the transition. I covered this in the modal pull-down tutorial mentioned earlier.

1. Select File New File…

2. Select iOS Source Swift File… and click Next

3. Name the file Interactor and click Create

4. Replace the file’s contents with the following code:

import UIKit

class Interactor: UIPercentDrivenInteractiveTransition {
    var hasStarted = false
    var shouldFinish = false
}

There are two flags:

  • hasStarted: Indicates whether an interaction is underway.
  • shouldFinish: Determines whether the interaction should complete, or roll back.

That was easy! The next file is going to be a little more complicated.

B. Create the MenuHelper

There are a couple things you’ll need to do each time you work with UIPercentDrivenInteractiveTransition:

  • Determine how far across the screen the user panned.
  • Sync the transition with the pan gesture state.

You’re going to create a MenuHelper file with methods to perform both of these tasks.

1. Select File New File…

2. Select iOS Source Swift File… and click Next

3. Name the file MenuHelper and click Create

4. Replace the file’s contents with the following code:

import Foundation
import UIKit

enum Direction {
    case Up
    case Down
    case Left
    case Right
}

struct MenuHelper {
    
    static let menuWidth:CGFloat = 0.8
    static let percentThreshold:CGFloat = 0.3
    static let snapshotNumber = 12345
    
}

The Direction enum has four directions. This tutorial only uses .Right for opening the menu, and .Left for closing it.

The MenuHelper is a struct with a few properties:

  • menuWidth: This constant defines the width of the slide-out menu. For now, it’s hard-coded to 80%.
  • percentThreshold: This is how far the user must pan before the menu changes state. This is set to 30%.
  • snapshotNumber: This is just a constant used to tag a snapshot view for later retrieval.

5. Add the calculateProgress method inside the struct body:

static func calculateProgress(translationInView:CGPoint, viewBounds:CGRect, direction:Direction) -> CGFloat {
    let pointOnAxis:CGFloat
    let axisLength:CGFloat
    switch direction {
    case .Up, .Down:
        pointOnAxis = translationInView.y
        axisLength = viewBounds.height
    case .Left, .Right:
        pointOnAxis = translationInView.x
        axisLength = viewBounds.width
    }
    let movementOnAxis = pointOnAxis / axisLength
    let positiveMovementOnAxis:Float
    let positiveMovementOnAxisPercent:Float
    switch direction {
    case .Right, .Down: // positive
        positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0)
        positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0)
        return CGFloat(positiveMovementOnAxisPercent)
    case .Up, .Left: // negative
        positiveMovementOnAxis = fminf(Float(movementOnAxis), 0.0)
        positiveMovementOnAxisPercent = fmaxf(positiveMovementOnAxis, -1.0)
        return CGFloat(-positiveMovementOnAxisPercent)
    }
}

This method accepts a few parameters:

  • translationInView: The user’s touch coordinates.
  • viewBounds: The screen’s dimensions.
  • direction: The direction that the slide-out menu is moving.

This method calculates the progress in a particular direction. For example, if you specify the .Right direction, it only cares about movement along the positive-direction. Likewise, .Left tracks progress in the negative-x direction.

6. Add the mapGestureStateToInteractor method inside the struct body:

static func mapGestureStateToInteractor(gestureState:UIGestureRecognizerState, progress:CGFloat, interactor: Interactor?, triggerSegue: () -> Void){
    guard let interactor = interactor else { return }
    switch gestureState {
    case .Began:
        interactor.hasStarted = true
        triggerSegue()
    case .Changed:
        interactor.shouldFinish = progress > percentThreshold
        interactor.updateInteractiveTransition(progress)
    case .Cancelled:
        interactor.hasStarted = false
        interactor.cancelInteractiveTransition()
    case .Ended:
        interactor.hasStarted = false
        interactor.shouldFinish
            ? interactor.finishInteractiveTransition()
            : interactor.cancelInteractiveTransition()
    default:
        break
    }
}

This method accepts a few parameters:

  • gestureState: The state of the pan gesture.
  • progress: How far across the screen the user has panned.
  • interactor: The UIPercentDrivenInteractiveTransition object that also serves as a state machine.
  • triggerSegue: A closure that is called to initiate the transition. The closure will contain something like performSegueWithIdentifier().

This method maps the pan gesture state to various Interactor method calls.

  • .Began: The hasStarted flag indicates that the interactive transition is in progress. Also, triggerSegue() is called to initiate the transition.

Note: Even though you just performed a segue, don’t worry — it won’t get very far before running into the other gesture states.

  • .Changed: The user’s progress is passed into the updateInteractiveTransition() method. For example, if the user dragged 50% across the screen, the transition animation will scrub to its halfway point.
  • .Cancelled: This maps directly to the cancelInteractiveTransition() method.
  • .Ended: Depending on how far the user panned, the interactor either finishes or cancels the transition.

Here’s the code so far:

3. Create the Present Menu Animator

Currently, the menu modal opens using the default slide-up animation. We want to override this with a custom animation where the main content slides-right to reveal the menu underneath.

Think of it like sliding off the top card from a deck. You will create a PresentMenuAnimator that will be responsible for this animation.

a. Select File New File…

b. Select iOS Source Swift File… and click Next

c. Name the file PresentMenuAnimator and click Create

d. Replace the file’s contents with the following code:

import UIKit

class PresentMenuAnimator : NSObject {
}

extension PresentMenuAnimator : UIViewControllerAnimatedTransitioning {
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 0.6
    }
    
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        guard
            let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
            let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
            let containerView = transitionContext.containerView()
            else {
                return
        }
        // more code goes here
    }
}

This is the basic structure of an Animator object.

  • NSObject: You subclass this to take advantage of the NSObjectProtocol.
  • UIViewControllerAnimatedTransitioning: This protocol implements the animation for a custom view controller transition.
  • transitionDuration(): This method determines how long the animation lasts.
  • animateTransition(): This method is where you define the custom animation.

animateTransition() gives you access to the two View Controllers involved in the transition.

  • fromVC: This is the MainViewController (blue).
  • toVC: This is the MenuViewController (green).
  • containerView: Think of this as the Window or main screen.

When the animation begins, the containerView already contains the fromVC. It’s up to you to add the toVC and (optionally) snapshot views.

containerFrom

e. Within animateTransition(), append the following code:

containerView.insertSubview(toVC.view, belowSubview: fromVC.view)

This inserts the Menu behind the Main View Controller.

addToVC

f. Still within animateTransition(), append the following code:

let snapshot = fromVC.view.snapshotViewAfterScreenUpdates(false)
snapshot.tag = MenuHelper.snapshotNumber
snapshot.userInteractionEnabled = false
snapshot.layer.shadowOpacity = 0.7
containerView.insertSubview(snapshot, aboveSubview: toVC.view)
fromVC.view.hidden = true

First, you create snapshot of the MainViewController. This serves two purposes:

  • The snapshot is just an image, so the user can’t accidentally interact with it.
  • Since the fromVC automatically gets removed after the transition, the snapshot gives the illusion that it’s still on the screen.

There’s a few other things you do to the snapshot:

  • tag: Serves as a handle you’ll use to remove the snapshot later on.
  • userInteractionEnabled: You set this to false so you can tap objects behind the snapshot. This becomes useful in a later step.
  • shadowOpacity: This is a visual cue that the snapshot floats above the green menu.

Now for the switcheroo: The snapshot is inserted above the Menu, while the real MainViewController is hidden.

addSnapshot

g. Again, within animateTransition(), append the following code:

UIView.animateWithDuration(
    transitionDuration(transitionContext),
    animations: {
        snapshot.center.x += UIScreen.mainScreen().bounds.width * MenuHelper.menuWidth
    },
    completion: { _ in
        fromVC.view.hidden = false
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
    }
)
  • animateWithDuration: The animation duration is set to 0.6 seconds.
  • animations: The snapshot is shifted to the right by 80% of the screen’s width.
  • completion: You set he MainViewController hidden state back to normal, so that it’s ready for next time.

centerXshift

4. Wire up the Present Menu Animator

Before your custom animation will start working, you need to wire it up. Replace MainViewController.swift with the following code:

import UIKit

class MainViewController: UIViewController {

    @IBAction func openMenu(sender: AnyObject) {
        performSegueWithIdentifier("openMenu", sender: nil)
    }
    
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if let destinationViewController = segue.destinationViewController as? MenuViewController {
            destinationViewController.transitioningDelegate = self
        }
    }

}

extension MainViewController: UIViewControllerTransitioningDelegate {
    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return PresentMenuAnimator()
    }
}

The UIViewControllerTransitioningDelegate lets you override the stock animation with a custom one. In this case, you are supplying the PresentMenuAnimator for the presentation transition.

Here’s the code so far:

Build and run.

Tap the Menu button, and the MainViewController will slide to reveal the menu underneath. Unfortunately, the Close button is now obscured. You’ll fix this in the next section.

presentMenuAnimator

5. Fix the Close Button

The modal’s Close button is hidden behind the snapshot. But this is actually a good thing. In fact, you’re going to adjust the constraints so that the Close button completely underlaps the snapshot.

closeButtonResized

Since you set snapshot.userInteractionEnabled to false in an earlier step, tap gestures pass right through the snapshot. This creates the illusion that the snapshot is tappable.

a. Within the Storyboard, find the MenuViewController scene (green).

b. Select the Close button, and open the Size inspector.

c. Add a vertical constraint to the Bottom Layout Guide.

d. Add an Equal Widths constraint to the super view.

e. Set the bottom layout vertical constraint constant to 0.

f. Set the equal width constraint’s multiplier to 0.2.

g. Click Resolve Auto Layout Issues (the advanced tie-fighter button) and select Update Frames.

h. Delete the title of the Close button.

closeButtonConstraints

Build and run. Now when you tap the snapshot, the menu closes.

tapToClose

The dismiss animation is still messed up, but you’ll fix this in the next section.

6. Create the Dismiss Menu Animator

Now that you can close the menu once again, it’s time to customize the dismiss animation. The process is very similar to creating the presentation animation in an earlier section.

a. Select File New File…

b. Select iOS Source Swift File… and click Next

c. Name the file DismissMenuAnimator and click Create

d. Replace the file’s contents with the following code:

import UIKit

class DismissMenuAnimator : NSObject {
}

extension DismissMenuAnimator : UIViewControllerAnimatedTransitioning {
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 0.6
    }
    
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        guard
            let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
            let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
            let containerView = transitionContext.containerView()
        else {
            return
        }
        // 1
        let snapshot = containerView.viewWithTag(MenuHelper.snapshotNumber) 
        
        UIView.animateWithDuration(
            transitionDuration(transitionContext),
            animations: {
                // 2
                snapshot?.frame = CGRect(origin: CGPoint.zero, size: UIScreen.mainScreen().bounds.size)
            },
            completion: { _ in
                let didTransitionComplete = !transitionContext.transitionWasCancelled()
                if didTransitionComplete {
                    // 3
                    containerView.insertSubview(toVC.view, aboveSubview: fromVC.view)
                    snapshot?.removeFromSuperview()
                }
                transitionContext.completeTransition(didTransitionComplete)
            }
        )
    }
}

This code is fairly similar to the PresentMenuAnimator you created earlier. You implement the UIViewControllerContextTransitioning protocol, along with its two required methods.

But this time, you’re dismissing instead of presenting, so the toVC and fromVC are reversed:

  • fromVC: This is the MenuViewController (green).
  • toVC: This is the MainViewController (blue).

As usual, the animation begins with the ContainerView and the fromVC. Since we never got rid of the snapshot, it’s right where we left it — shifted to the right by 80% of the screen’s width.

snapshotTag

  • Comment #1: The first step is to get a handle of this snapshot view. Luckily, you tagged the snapshot in an earlier step, so you can retrieve it using viewWithTag().
  • Comment #2: The animation moves the snapshot back to the center of the screen.
  • Comment #3: If the animation finishes, replace the snapshot with the real thing.

7. Wire up the Dismiss Menu Animator

To see the dismiss animator in action, wire it up to the appropriate transitioning delegate method.

In MainViewController.swift, append the following code within the UIViewControllerTransitioningDelegate extension:

func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    return DismissMenuAnimator()
}

Here’s the code so far:

Build and run. The closing animation should now slide the main content area back to its original position.

closingAnimation

8. Add the Dismiss Interaction

The next step is to close the menu interactively.

You’ll use a Pan Gesture Recognizer to drive the transition. As the user drags horizontally, the dismiss animation will scrub to its corresponding progress point.

Open MenuViewController.swift and replace its contents with the following code:

import UIKit

class MenuViewController : UIViewController {
    // 1
    var interactor:Interactor? = nil
    // 2
    @IBAction func handleGesture(sender: UIPanGestureRecognizer) {
        // 3
        let translation = sender.translationInView(view)
        // 4
        let progress = MenuHelper.calculateProgress(
            translation, 
            viewBounds: view.bounds, 
            direction: .Left
        )
        // 5
        MenuHelper.mapGestureStateToInteractor(
            sender.state,
            progress: progress,
            interactor: interactor){
                // 6
                self.dismissViewControllerAnimated(true, completion: nil)
        }
    }
    
    @IBAction func closeMenu(sender: AnyObject) {
        dismissViewControllerAnimated(true, completion: nil)
    }
}

Here’s what’s going on:

  • Comment #1: The MainViewController passes the interactor object to the MenuViewController. This is how they share state.
  • Comment #2: You create an @IBAction for a pan gesture.  You’ll wire this up in the Storyboard later.
  • Comment #3: You use translationInView() to get the pan gesture coordinates.
  • Comment #4: Using the MenuHelper‘s calculateProgress() method, you convert the coordinates into progress in a specific direction.
  • Comment #5: You pass all the information you have to the MenuHelper‘s mapGestureStateToInteractor() method. This does the hard work of syncing the gesture state with the interactive transition.
  • Comment #6: It’s important to note that this trailing closure is not a completion handler. Rather, you pass in the line of code that initiates the transition. In this case, it’s dismissViewControllerAnimated().

All of this code is essentially trying to update the transition status each time a pan gesture event is fired. Feel free to refer back to the MenuHelper methods in the add some helpful files section.

9. Add a Pan Gesture Recognizer

You just added the code for an @IBAction for a pan gesture recognizer to the MenuViewController. Now it’s time to wire this up in the Storyboard.

a. In the Storyboard, search for a Pan Gesture Recognizer in the Object Library.

b. Drag it onto the Close button.

c. Hold down Control and drag from the gray Pan Gesture Recognizer icon to the yellow MenuViewController Scene icon.

d. Select the handleGesture: action from the popover menu.

wirePanGestureToCloseButton

Thanks to a reader named Aly, I learned it’s better to attach the Pan Gesture Recognizer to the Close button rather than the background view. This makes the snapshot feel like a real object. Also, you avoid gesture conflicts with the menu’s table should you decide to implement row swipe actions.

10. Wire up the Dismiss Interaction

There’s one more step before any of this works. You have to inform the MainViewController that you’ll take responsibility for the interactive portion of the dismiss transition.

Replace the contents of MainViewController.swift with the following code:

import UIKit

class MainViewController: UIViewController {
    // 1
    let interactor = Interactor()
    
    @IBAction func openMenu(sender: AnyObject) {
        performSegueWithIdentifier("openMenu", sender: nil)
    }
    
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if let destinationViewController = segue.destinationViewController as? MenuViewController {
            destinationViewController.transitioningDelegate = self
            // 2
            destinationViewController.interactor = interactor
        }
    }
}

extension MainViewController: UIViewControllerTransitioningDelegate {
    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return PresentMenuAnimator()
    }
    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return DismissMenuAnimator()
    }
    // 3
    func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactor.hasStarted ? interactor : nil
    }
}
  • Comment #1: Remember that interactor object you keep passing around? This is where you create it.
  • Comment #2: You pass the interactor object forward in prepareForSegue().
  • Comment #3: You indicate that the dismiss transition is going to be interactive, but only if the user is panning. (Remember that hasStarted was set to true when the pan gesture began.)

Here’s the code so far:

Build and run. You should now be able to close the menu by dragging the snapshot.

closingInteraction

11. Add the Present Interaction

You’re almost done! You can actually call it quits now, and still end up with a pretty decent slide-out menu. But wouldn’t it be nice to open the menu interactively as well?

Rather than use a normal pan gesture recognizer, you’re going to use a Screen Edge Pan Gesture Recognizer instead. It doesn’t interfere as badly with horizontal gestures in the main content area.

As with any gesture, you’ll still need to onboard your users so that they’re aware of this feature. But hopefully this gesture will be somewhat familiar with users who are accustomed with swiping-to-go-back.

Open MainViewController.swift and add the following code:

@IBAction func edgePanGesture(sender: UIScreenEdgePanGestureRecognizer) {
    let translation = sender.translationInView(view)
    
    let progress = MenuHelper.calculateProgress(translation, viewBounds: view.bounds, direction: .Right)
    
    MenuHelper.mapGestureStateToInteractor(
        sender.state,
        progress: progress,
        interactor: interactor){
            self.performSegueWithIdentifier("openMenu", sender: nil)
    }
}

This is similar to the pan gesture @IBAction you added to the MenuViewController. The edgePanGesture uses the same MenuHelper methods to sync the gesture state with the interactive transition.

12. Add a Screen Edge Pan Gesture Recognizer

Now to add the screen edge pan gesture to the Storyboard.

a. In the Storyboard, search for a Screen Edge Pan Gesture Recognizer in the Object Library.

b. Drag it onto the MainViewController (blue).

c. Hold down Control and drag from the gray Screen Edge Pan Gesture icon to the yellow MainViewController Scene icon.

d. Select the edgePanGesture: action from the popover menu.

e. In the Attributes Inspector of the Screen Edge Pan Gesture, select the Left checkbox.

edgePanGesture

The process of wiring up the gesture is similar to what you did in a previous section. The only difference is that you have to configure the screen edge pan gesture to use the left edge.

13. Wire up the Present Interaction

Finally, the last step is to tell the transitioning delegate that you’re in charge of the presentation interaction.

Open MainViewController.swift and add the following code to the UIViewControllerAnimatedTransitioning extension:

func interactionControllerForPresentation(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    return interactor.hasStarted ? interactor : nil
}

And that’s pretty much it.

Here’s the code so far:

Build and run. You should now be able to swipe from the left edge to open the menu.

leftEdgePanToOpen

Conclusion

You can check out the completed project on GitHub. If you’re pulling from the latest commit, you might see some sample table view cells that that open detail pages.

I think using Custom View Controller Transitions is a decent approach for making interactive slide-out menus. It looks like you have two View Controllers on the screen, but you’re actually just using a snapshot. Also, you can customize the animation to support pretty much any slide-out implementation.

If people are interested, I might consider follow-up blog posts on customizing different slide-out animations. But in the meantime, you can check out what others have done here and here.

Got any tips for working with custom transitions? Have any suggestions for future tutorial topics? Feel free to add your thoughts to the comments.

Like this post? Please share it using the share buttons to the left. Then join our mailing list below and follow us on Twitter – @thorntech – for future updates.

Get insights on SFTP Gateway, cloud computing and more, in your inbox.

Get smarter about all things tech. Sign up now!

Scroll to Top