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.