2 // SideMenuTransition.swift
5 // Created by Jon Kent on 1/14/16.
11 open class SideMenuTransition: UIPercentDrivenInteractiveTransition {
13 fileprivate var presenting = false
14 fileprivate var interactive = false
15 fileprivate weak var originalSuperview: UIView?
16 fileprivate weak var activeGesture: UIGestureRecognizer?
17 fileprivate var switchMenus = false {
24 fileprivate var menuWidth: CGFloat {
26 let overriddenWidth = menuViewController?.menuWidth ?? 0
27 if overriddenWidth > CGFloat.ulpOfOne {
28 return overriddenWidth
30 return sideMenuManager.menuWidth
33 internal weak var sideMenuManager: SideMenuManager!
34 internal weak var mainViewController: UIViewController?
35 internal weak var menuViewController: UISideMenuNavigationController? {
37 return presentDirection == .left ? sideMenuManager.menuLeftNavigationController : sideMenuManager.menuRightNavigationController
40 internal var presentDirection: UIRectEdge = .left
41 internal weak var tapView: UIView? {
43 guard let tapView = tapView else {
47 tapView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
48 let exitPanGesture = UIPanGestureRecognizer()
49 exitPanGesture.addTarget(self, action:#selector(SideMenuTransition.handleHideMenuPan(_:)))
50 let exitTapGesture = UITapGestureRecognizer()
51 exitTapGesture.addTarget(self, action: #selector(SideMenuTransition.handleHideMenuTap(_:)))
52 tapView.addGestureRecognizer(exitPanGesture)
53 tapView.addGestureRecognizer(exitTapGesture)
56 internal weak var statusBarView: UIView? {
58 guard let statusBarView = statusBarView else {
62 statusBarView.backgroundColor = sideMenuManager.menuAnimationBackgroundColor ?? UIColor.black
63 statusBarView.isUserInteractionEnabled = false
67 required public init(sideMenuManager: SideMenuManager) {
70 NotificationCenter.default.addObserver(self, selector:#selector(handleNotification), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil)
71 NotificationCenter.default.addObserver(self, selector:#selector(handleNotification), name: NSNotification.Name.UIApplicationWillChangeStatusBarFrame, object: nil)
72 self.sideMenuManager = sideMenuManager
76 NotificationCenter.default.removeObserver(self)
79 fileprivate static var visibleViewController: UIViewController? {
81 return getVisibleViewController(forViewController: UIApplication.shared.keyWindow?.rootViewController)
85 fileprivate class func getVisibleViewController(forViewController: UIViewController?) -> UIViewController? {
86 if let navigationController = forViewController as? UINavigationController {
87 return getVisibleViewController(forViewController: navigationController.visibleViewController)
89 if let tabBarController = forViewController as? UITabBarController {
90 return getVisibleViewController(forViewController: tabBarController.selectedViewController)
92 if let splitViewController = forViewController as? UISplitViewController {
93 return getVisibleViewController(forViewController: splitViewController.viewControllers.last)
95 if let presentedViewController = forViewController?.presentedViewController {
96 return getVisibleViewController(forViewController: presentedViewController)
99 return forViewController
102 @objc internal func handlePresentMenuLeftScreenEdge(_ edge: UIScreenEdgePanGestureRecognizer) {
103 presentDirection = .left
104 handlePresentMenuPan(edge)
107 @objc internal func handlePresentMenuRightScreenEdge(_ edge: UIScreenEdgePanGestureRecognizer) {
108 presentDirection = .right
109 handlePresentMenuPan(edge)
112 @objc internal func handlePresentMenuPan(_ pan: UIPanGestureRecognizer) {
113 if activeGesture == nil {
115 } else if pan != activeGesture {
116 pan.isEnabled = false
119 } else if pan.state != .began && pan.state != .changed {
123 // how much distance have we panned in reference to the parent view?
124 guard let view = mainViewController?.view ?? pan.view else {
128 let transform = view.transform
129 view.transform = .identity
130 let translation = pan.translation(in: pan.view!)
131 view.transform = transform
133 // do some math to translate this to a percentage based value
135 if translation.x == 0 {
136 return // not sure which way the user is swiping yet, so do nothing
139 if !(pan is UIScreenEdgePanGestureRecognizer) {
140 presentDirection = translation.x > 0 ? .left : .right
143 if let menuViewController = menuViewController, let visibleViewController = SideMenuTransition.visibleViewController {
145 visibleViewController.present(menuViewController, animated: true, completion: nil)
151 let direction: CGFloat = presentDirection == .left ? 1 : -1
152 let distance = translation.x / menuWidth
153 // now lets deal with different states that the gesture recognizer sends
155 case .began, .changed:
156 if pan is UIScreenEdgePanGestureRecognizer {
157 update(min(distance * direction, 1))
158 } else if distance > 0 && presentDirection == .right && sideMenuManager.menuLeftNavigationController != nil {
159 presentDirection = .left
161 } else if distance < 0 && presentDirection == .left && sideMenuManager.menuRightNavigationController != nil {
162 presentDirection = .right
165 update(min(distance * direction, 1))
169 view.transform = .identity
170 let velocity = pan.velocity(in: pan.view!).x * direction
171 view.transform = transform
172 if velocity >= 100 || velocity >= -50 && abs(distance) >= 0.5 {
173 // bug workaround: animation briefly resets after call to finishInteractiveTransition() but before animateTransition completion is called.
174 if ProcessInfo().operatingSystemVersion.majorVersion == 8 && percentComplete > 1 - CGFloat.ulpOfOne {
184 @objc internal func handleHideMenuPan(_ pan: UIPanGestureRecognizer) {
185 if activeGesture == nil {
187 } else if pan != activeGesture {
188 pan.isEnabled = false
193 let translation = pan.translation(in: pan.view!)
194 let direction:CGFloat = presentDirection == .left ? -1 : 1
195 let distance = translation.x / menuWidth * direction
201 mainViewController?.dismiss(animated: true, completion: nil)
203 update(max(min(distance, 1), 0))
206 let velocity = pan.velocity(in: pan.view!).x * direction
207 if velocity >= 100 || velocity >= -50 && distance >= 0.5 {
208 // bug workaround: animation briefly resets after call to finishInteractiveTransition() but before animateTransition completion is called.
209 if ProcessInfo().operatingSystemVersion.majorVersion == 8 && percentComplete > 1 - CGFloat.ulpOfOne {
221 @objc internal func handleHideMenuTap(_ tap: UITapGestureRecognizer) {
222 menuViewController?.dismiss(animated: true, completion: nil)
225 @discardableResult internal func hideMenuStart() -> SideMenuTransition {
226 let menuView = menuViewController?.view
227 let mainView = mainViewController?.view
229 mainView?.transform = .identity
231 mainView?.frame.origin = .zero
232 menuView?.transform = .identity
233 menuView?.frame.origin.y = 0
234 menuView?.frame.size.width = menuWidth
235 menuView?.frame.size.height = mainView?.frame.height ?? 0 // in case status bar height changed
236 var statusBarFrame = UIApplication.shared.statusBarFrame
237 let statusBarOffset = SideMenuManager.appScreenRect.size.height - (mainView?.frame.maxY ?? 0)
238 // For in-call status bar, height is normally 40, which overlaps view. Instead, calculate height difference
239 // of view and set height to fill in remaining space.
240 if statusBarOffset >= CGFloat.ulpOfOne {
241 statusBarFrame.size.height = statusBarOffset
243 statusBarView?.frame = statusBarFrame
244 statusBarView?.alpha = 0
246 switch sideMenuManager.menuPresentMode {
249 menuView?.alpha = 1 - sideMenuManager.menuAnimationFadeStrength
250 menuView?.frame.origin.x = presentDirection == .left ? 0 : (mainView?.frame.width ?? 0) - menuWidth
251 menuView?.transform = CGAffineTransform(scaleX: sideMenuManager.menuAnimationTransformScaleFactor, y: sideMenuManager.menuAnimationTransformScaleFactor)
253 case .viewSlideInOut:
255 menuView?.frame.origin.x = presentDirection == .left ? -menuView!.frame.width : mainView!.frame.width
259 menuView?.frame.origin.x = presentDirection == .left ? -menuView!.frame.width : mainView!.frame.width
261 case .menuDissolveIn:
263 menuView?.frame.origin.x = presentDirection == .left ? 0 : mainView!.frame.width - menuWidth
269 @discardableResult internal func hideMenuComplete() -> SideMenuTransition {
270 let menuView = menuViewController?.view
271 let mainView = mainViewController?.view
273 tapView?.removeFromSuperview()
274 statusBarView?.removeFromSuperview()
275 mainView?.motionEffects.removeAll()
276 mainView?.layer.shadowOpacity = 0
277 menuView?.layer.shadowOpacity = 0
278 if let topNavigationController = mainViewController as? UINavigationController {
279 topNavigationController.interactivePopGestureRecognizer!.isEnabled = true
281 if let originalSuperview = originalSuperview, let mainView = mainViewController?.view {
282 originalSuperview.addSubview(mainView)
283 let y = originalSuperview.bounds.height - mainView.frame.size.height
284 mainView.frame.origin.y = max(y, 0)
287 originalSuperview = nil
288 mainViewController = nil
293 @discardableResult internal func presentMenuStart() -> SideMenuTransition {
294 let menuView = menuViewController?.view
295 let mainView = mainViewController?.view
298 menuView?.transform = .identity
299 menuView?.frame.size.width = menuWidth
300 let size = SideMenuManager.appScreenRect.size
301 menuView?.frame.origin.x = presentDirection == .left ? 0 : size.width - menuWidth
302 mainView?.transform = .identity
303 mainView?.frame.size.width = size.width
304 let statusBarOffset = size.height - (menuView?.bounds.height ?? 0)
305 mainView?.bounds.size.height = size.height - max(statusBarOffset, 0)
306 mainView?.frame.origin.y = 0
307 var statusBarFrame = UIApplication.shared.statusBarFrame
308 // For in-call status bar, height is normally 40, which overlaps view. Instead, calculate height difference
309 // of view and set height to fill in remaining space.
310 if statusBarOffset >= CGFloat.ulpOfOne {
311 statusBarFrame.size.height = statusBarOffset
313 tapView?.transform = .identity
314 tapView?.bounds = mainView!.bounds
315 statusBarView?.frame = statusBarFrame
316 statusBarView?.alpha = 1
318 switch sideMenuManager.menuPresentMode {
320 case .viewSlideOut, .viewSlideInOut:
321 mainView?.layer.shadowColor = sideMenuManager.menuShadowColor.cgColor
322 mainView?.layer.shadowRadius = sideMenuManager.menuShadowRadius
323 mainView?.layer.shadowOpacity = sideMenuManager.menuShadowOpacity
324 mainView?.layer.shadowOffset = CGSize(width: 0, height: 0)
325 let direction:CGFloat = presentDirection == .left ? 1 : -1
326 mainView?.frame.origin.x = direction * (menuView!.frame.width)
328 case .menuSlideIn, .menuDissolveIn:
329 if sideMenuManager.menuBlurEffectStyle == nil {
330 menuView?.layer.shadowColor = sideMenuManager.menuShadowColor.cgColor
331 menuView?.layer.shadowRadius = sideMenuManager.menuShadowRadius
332 menuView?.layer.shadowOpacity = sideMenuManager.menuShadowOpacity
333 menuView?.layer.shadowOffset = CGSize(width: 0, height: 0)
335 mainView?.frame.origin.x = 0
338 if sideMenuManager.menuPresentMode != .viewSlideOut {
339 mainView?.transform = CGAffineTransform(scaleX: sideMenuManager.menuAnimationTransformScaleFactor, y: sideMenuManager.menuAnimationTransformScaleFactor)
340 if sideMenuManager.menuAnimationTransformScaleFactor > 1 {
341 tapView?.transform = mainView!.transform
343 mainView?.alpha = 1 - sideMenuManager.menuAnimationFadeStrength
349 @discardableResult internal func presentMenuComplete() -> SideMenuTransition {
350 switch sideMenuManager.menuPresentMode {
351 case .menuSlideIn, .menuDissolveIn, .viewSlideInOut:
352 if let mainView = mainViewController?.view, sideMenuManager.menuParallaxStrength != 0 {
353 let horizontal = UIInterpolatingMotionEffect(keyPath: "center.x", type: .tiltAlongHorizontalAxis)
354 horizontal.minimumRelativeValue = -sideMenuManager.menuParallaxStrength
355 horizontal.maximumRelativeValue = sideMenuManager.menuParallaxStrength
357 let vertical = UIInterpolatingMotionEffect(keyPath: "center.y", type: .tiltAlongVerticalAxis)
358 vertical.minimumRelativeValue = -sideMenuManager.menuParallaxStrength
359 vertical.maximumRelativeValue = sideMenuManager.menuParallaxStrength
361 let group = UIMotionEffectGroup()
362 group.motionEffects = [horizontal, vertical]
363 mainView.addMotionEffect(group)
365 case .viewSlideOut: break;
367 if let topNavigationController = mainViewController as? UINavigationController {
368 topNavigationController.interactivePopGestureRecognizer!.isEnabled = false
374 @objc internal func handleNotification(notification: NSNotification) {
375 guard menuViewController?.presentedViewController == nil &&
376 menuViewController?.presentingViewController != nil else {
380 if let originalSuperview = originalSuperview, let mainViewController = mainViewController {
381 originalSuperview.addSubview(mainViewController.view)
384 if notification.name == NSNotification.Name.UIApplicationDidEnterBackground {
385 hideMenuStart().hideMenuComplete()
386 menuViewController?.dismiss(animated: false, completion: nil)
390 UIView.animate(withDuration: sideMenuManager.menuAnimationDismissDuration,
392 usingSpringWithDamping: sideMenuManager.menuAnimationUsingSpringWithDamping,
393 initialSpringVelocity: sideMenuManager.menuAnimationInitialSpringVelocity,
394 options: sideMenuManager.menuAnimationOptions,
397 }) { (finished) -> Void in
398 self.hideMenuComplete()
399 self.menuViewController?.dismiss(animated: false, completion: nil)
405 extension SideMenuTransition: UIViewControllerAnimatedTransitioning {
407 // animate a change from one viewcontroller to another
408 open func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
410 // get reference to our fromView, toView and the container view that we should perform the transition in
411 let container = transitionContext.containerView
412 // prevent any other menu gestures from firing
413 container.isUserInteractionEnabled = false
415 if let menuBackgroundColor = sideMenuManager.menuAnimationBackgroundColor {
416 container.backgroundColor = menuBackgroundColor
419 let fromViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!
420 let toViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!
422 // assign references to our menu view controller and the 'bottom' view controller from the tuple
423 // remember that our menuViewController will alternate between the from and to view controller depending if we're presenting or dismissing
424 mainViewController = presenting ? fromViewController : toViewController
426 let menuView = menuViewController!.view!
427 let topView = mainViewController!.view!
429 // prepare menu items to slide in
431 originalSuperview = topView.superview
433 // add the both views to our view controller
434 switch sideMenuManager.menuPresentMode {
435 case .viewSlideOut, .viewSlideInOut:
436 container.addSubview(menuView)
437 container.addSubview(topView)
438 case .menuSlideIn, .menuDissolveIn:
439 container.addSubview(topView)
440 container.addSubview(menuView)
443 if sideMenuManager.menuFadeStatusBar {
444 let statusBarView = UIView()
445 self.statusBarView = statusBarView
446 container.addSubview(statusBarView)
454 self.presentMenuStart()
461 container.isUserInteractionEnabled = true
463 // tell our transitionContext object that we've finished animating
464 if transitionContext.transitionWasCancelled {
465 let viewControllerForPresentedMenu = self.mainViewController
468 self.hideMenuComplete()
470 self.presentMenuComplete()
473 transitionContext.completeTransition(false)
475 if self.switchMenus {
476 self.switchMenus = false
477 viewControllerForPresentedMenu?.present(self.menuViewController!, animated: true, completion: nil)
484 self.presentMenuComplete()
485 transitionContext.completeTransition(true)
486 switch self.sideMenuManager.menuPresentMode {
487 case .viewSlideOut, .viewSlideInOut:
488 container.addSubview(topView)
489 case .menuSlideIn, .menuDissolveIn:
490 container.insertSubview(topView, at: 0)
492 if !self.sideMenuManager.menuPresentingViewControllerUserInteractionEnabled {
493 let tapView = UIView()
494 container.insertSubview(tapView, aboveSubview: topView)
495 tapView.bounds = container.bounds
496 tapView.center = topView.center
497 if self.sideMenuManager.menuAnimationTransformScaleFactor > 1 {
498 tapView.transform = topView.transform
500 self.tapView = tapView
502 if let statusBarView = self.statusBarView {
503 container.bringSubview(toFront: statusBarView)
509 self.hideMenuComplete()
510 transitionContext.completeTransition(true)
511 menuView.removeFromSuperview()
514 // perform the animation!
515 let duration = transitionDuration(using: transitionContext)
517 UIView.animate(withDuration: duration,
518 delay: duration, // HACK: If zero, the animation briefly flashes in iOS 11. UIViewPropertyAnimators (iOS 10+) may resolve this.
519 options: .curveLinear,
522 }, completion: { (finished) in
526 UIView.animate(withDuration: duration,
528 usingSpringWithDamping: sideMenuManager.menuAnimationUsingSpringWithDamping,
529 initialSpringVelocity: sideMenuManager.menuAnimationInitialSpringVelocity,
530 options: sideMenuManager.menuAnimationOptions,
533 }) { (finished) -> Void in
539 // return how many seconds the transiton animation will take
540 open func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
542 return sideMenuManager.menuAnimationCompleteGestureDuration
544 return presenting ? sideMenuManager.menuAnimationPresentDuration : sideMenuManager.menuAnimationDismissDuration
547 open override func update(_ percentComplete: CGFloat) {
548 guard !switchMenus else {
552 super.update(percentComplete)
557 extension SideMenuTransition: UIViewControllerTransitioningDelegate {
559 // return the animator when presenting a viewcontroller
560 // rememeber that an animator (or animation controller) is any object that aheres to the UIViewControllerAnimatedTransitioning protocol
561 open func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
562 self.presenting = true
563 presentDirection = presented == sideMenuManager.menuLeftNavigationController ? .left : .right
567 // return the animator used when dismissing from a viewcontroller
568 open func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
573 open func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
574 // if our interactive flag is true, return the transition manager object
575 // otherwise return nil
576 return interactive ? self : nil
579 open func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
580 return interactive ? self : nil