Saturday, February 20, 2016

Slide-out menu (with hamburger).

This short piece is about interactive slide-out menu in iOS. The demo is using an embedded container view for a menu view controller, and that controller handles animations and interaction.

The following implementation will be used for interaction-animation control where view hierarchy remains mostly static.




1. Menu view consists of two container views - a full screen dim view and an overlapping menu view. Interaction is registered by a pan gesture recognizer and is converted to a normalized “progress” property value. Setting this property on a menu view controller updates both the alpha property of the dim view and an origin of a menu view (via a horizontal leading constraint). 


import UIKit


class RootViewController: UIViewController, UIGestureRecognizerDelegate{

    @IBOutlet weak var menuContainerView: UIView!
    @IBOutlet weak var mainContainerView: UIView!
    
    weak var menuVC: MenuViewController!;

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        
        // Instead of fishing for an embedded controller in a prepareForSegue.
        for vc in childViewControllers {
            if let mvc = vc as? MenuViewController {
                menuVC = mvc;
            }
        }
    }

    
    // MARK: - Gesture Recognizer
    
    var initialProgress: Float = 0.0;
    var ignoreGesture = false;

    @IBAction func handlePanGesture(recognizer: UIPanGestureRecognizer) {
        let vel = recognizer.velocityInView(view);
        let velThreshold = menuVC.velocityThreshold;

        switch(recognizer.state) {
        case .Began:
            ignoreGesture = abs(vel.y) > abs(vel.x);
            if (ignoreGesture) {
                return;
            }
            initialProgress = menuVC.progress;
            recognizer.setTranslation(CGPointZero, inView: view)
            
        case .Changed:
            if (ignoreGesture) {
                return;
            }
            let translation = recognizer.translationInView(view);
            let tX = Float(translation.x);
            let progress = initialProgress + tX / velThreshold;
            // The following can be used to prevent menu progress overshot (not a good "responsive UI").
            // progress = min(1.0, max(0.0, progress));
            menuVC.progress = progress;
            
        case .Ended:
            if (ignoreGesture) {
                return;
            }
            let velX = Float(vel.x);
            let expand = ((menuVC.progress > 0.5 && velX > -velThreshold) || (menuVC.progress < 0.5 && velX > velThreshold));
            menuVC.animateMenu(expand);
            
        default:
            break
        }
    }
    
}



2. Progress property setter does a few things differently if the value is to be animated. Because menu works in conjunction with the embedding superview, the progress will enable/disable the superview as needed so that collapsed menu stays out of way when user interacts with the main content. To animate this property from its current value - there is a function animateMenu:.


import UIKit

class MenuViewController: UIViewController {
    @IBOutlet weak var dimView: UIView!
    @IBOutlet weak var menuView: UIView!
    @IBOutlet weak var menuLeadingConstraint: NSLayoutConstraint!

    var _useAnimation = false;
    var _progress: Float = 0.0;
    
    // To animate to a value set the _useAnimation true.
    var progress: Float {
        get { return _progress; }
        set {
            if (abs(newValue) > 1.0) {
                _progress = min(1.5, pow(abs(newValue), 0.2) * (newValue >= 0.0 ? 1.0 : -1.0));
            }
            else {
                _progress = newValue;
            }
            if (newValue > 1e-5 && !menuEnabled) {
                // Don't just set to condition via assignment, "false" will break animation (0.0 will collapse the menu before animation begins).
                menuEnabled = true;
            }
            
            let animations = {
                self.dimView.alpha = CGFloat(min(1.0, self._progress) / 2);
                let menuOriginX = CGFloat(self.velocityThreshold * self._progress) - self.menuView.frame.width;
                self.menuLeadingConstraint.constant = menuOriginX;
                self.view.layoutIfNeeded();
            };
            let completion: (Bool -> Void) = { finished in
                if (newValue <= 1e-5) {
                    self.menuEnabled = false;
                }
                self._useAnimation = false;
            };

            if (_useAnimation) {
                UIView.animateWithDuration(0.25, delay: 0.0, options: .CurveEaseOut, animations: animations, completion: completion);
            } else {
                animations();
                completion(true);
            }
        }
    }
    
    private(set) var velocityThreshold: Float = 100.0;
    
    // Enables/disables container view.
    var menuEnabled: Bool {
        get {
            return !view.superview!.hidden;
        }
        set {
            view.superview!.hidden = !newValue;
        }
    }

    func animateMenu(expand: Bool) {
        _useAnimation = true;
        
        if (expand) {
            progress = 1;
        } else {
            progress = 0;
        }
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        
        // Initial menu view offset is a design-time position for an "expanded" state.
        velocityThreshold = Float(menuView.frame.width + menuView.frame.origin.x);
    }
    
    override func viewDidAppear(animated: Bool) {
        menuEnabled = true;
        progress = 1.0;
        animateMenu(false);
    }

    @IBAction func tapDimView(sender: AnyObject) {
        animateMenu(false);
    }
}




Other ways to implement this, such as using UIScrollView or custom transition animators, are more appropriate if standard transitions (on navigation controller) are desired. I find them either lacking in brevity of code or having tight dependence on standard view controllers.