Files
SideMenu/Pod/Classes/SideMenuNavigationController.swift
2019-08-19 14:08:54 +03:00

693 lines
28 KiB
Swift

//
// SideMenuNavigationController.swift
//
// Created by Jon Kent on 1/14/16.
// Copyright © 2016 Jon Kent. All rights reserved.
//
import UIKit
@objc public enum SideMenuPushStyle: Int { case
`default`,
popWhenPossible,
preserve,
preserveAndHideBackButton,
replace,
subMenu
}
@objc public protocol SideMenuNavigationControllerDelegate {
@objc optional func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool)
@objc optional func sideMenuDidAppear(menu: SideMenuNavigationController, animated: Bool)
@objc optional func sideMenuWillDisappear(menu: SideMenuNavigationController, animated: Bool)
@objc optional func sideMenuDidDisappear(menu: SideMenuNavigationController, animated: Bool)
}
internal protocol SideMenuNavigationControllerTransitionDelegate: class {
func sideMenuTransitionDidDismiss(menu: Menu)
}
public struct SideMenuSettings: MenuModel, InitializableStruct {
public var allowPushOfSameClassTwice: Bool = true
public var alwaysAnimate: Bool = true
public var animationOptions: UIView.AnimationOptions = .curveEaseInOut
public var blurEffectStyle: UIBlurEffect.Style? = nil
public var completeGestureDuration: Double = 0.35
public var completionCurve: UIView.AnimationCurve = .easeIn
public var dismissDuration: Double = 0.35
public var dismissOnPresent: Bool = true
public var dismissOnPush: Bool = true
public var dismissOnRotation: Bool = true
public var dismissWhenBackgrounded: Bool = true
public var enableSwipeToDismissGesture: Bool = true
public var enableTapToDismissGesture: Bool = true
public var initialSpringVelocity: CGFloat = 1
public var menuWidth: CGFloat = {
let appScreenRect = UIApplication.shared.keyWindow?.bounds ?? UIWindow().bounds
let minimumSize = min(appScreenRect.width, appScreenRect.height)
return min(round(minimumSize * 0.75), 240)
}()
public var presentingViewControllerUserInteractionEnabled: Bool = false
public var presentingViewControllerUseSnapshot: Bool = false
public var presentDuration: Double = 0.35
public var presentationStyle: SideMenuPresentationStyle = .viewSlideOut
public var pushStyle: SideMenuPushStyle = .default
public var statusBarEndAlpha: CGFloat = 1
public var usingSpringWithDamping: CGFloat = 1
public init() {}
}
internal typealias Menu = SideMenuNavigationController
@objcMembers
open class SideMenuNavigationController: UINavigationController {
private lazy var _leftSide = Protected(false) { [weak self] oldValue, newValue in
guard self?.isHidden != false else {
Print.warning(.property, arguments: .leftSide, required: true)
return oldValue
}
return newValue
}
private weak var _sideMenuManager: SideMenuManager?
private weak var foundViewController: UIViewController?
private weak var interactionController: SideMenuInteractionController?
private var interactive: Bool = false
private var originalBackgroundColor: UIColor?
private var rotating: Bool = false
private var transitionController: SideMenuTransitionController?
/// Delegate for receiving appear and disappear related events. If `nil` the visible view controller that displays a `SideMenuNavigationController` automatically receives these events.
public weak var sideMenuDelegate: SideMenuNavigationControllerDelegate?
/// The swipe to dismiss gesture.
open private(set) weak var swipeToDismissGesture: UIPanGestureRecognizer? = nil
/// The tap to dismiss gesture.
open private(set) weak var tapToDismissGesture: UITapGestureRecognizer? = nil
open var sideMenuManager: SideMenuManager {
get { return _sideMenuManager ?? SideMenuManager.default }
set {
newValue.setMenu(self, forLeftSide: leftSide)
if let sideMenuManager = _sideMenuManager, sideMenuManager !== newValue {
let side = SideMenuManager.PresentDirection(leftSide: leftSide)
Print.warning(.menuAlreadyAssigned, arguments: String(describing: self.self), side.name, String(describing: newValue))
}
_sideMenuManager = newValue
}
}
/// The menu settings.
open var settings = SideMenuSettings() {
didSet {
setupBlur()
if !enableSwipeToDismissGesture {
removeSwipeGesture()
}
if !enableTapToDismissGesture {
removeTapGesture()
}
}
}
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
setup()
}
public init(rootViewController: UIViewController, settings: SideMenuSettings = SideMenuSettings()) {
super.init(rootViewController: rootViewController)
self.settings = settings
setup()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
override open func awakeFromNib() {
super.awakeFromNib()
sideMenuManager.setMenu(self, forLeftSide: leftSide)
}
override open func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if topViewController == nil {
Print.warning(.emptyMenu)
}
// Dismiss keyboard to prevent weird keyboard animations from occurring during transition
presentingViewController?.view.endEditing(true)
foundViewController = nil
activeDelegate?.sideMenuWillAppear?(menu: self, animated: animated)
}
override open func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// We had presented a view before, so lets dismiss ourselves as already acted upon
if view.isHidden {
transitionController?.transition(presenting: false, animated: false)
dismiss(animated: false, completion: { [weak self] in
self?.view.isHidden = false
})
} else {
activeDelegate?.sideMenuDidAppear?(menu: self, animated: animated)
}
}
override open func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
defer {
activeDelegate?.sideMenuWillDisappear?(menu: self, animated: animated)
}
guard !isBeingDismissed else { return }
// When presenting a view controller from the menu, the menu view gets moved into another transition view above our transition container
// which can break the visual layout we had before. So, we move the menu view back to its original transition view to preserve it.
if let presentingView = presentingViewController?.view, let containerView = presentingView.superview {
containerView.addSubview(view)
}
if dismissOnPresent {
// We're presenting a view controller from the menu, so we need to hide the menu so it isn't showing when the presented view is dismissed.
transitionController?.transition(presenting: false, animated: animated, alongsideTransition: { [weak self] in
guard let self = self else { return }
self.activeDelegate?.sideMenuWillDisappear?(menu: self, animated: animated)
}, complete: false, completion: { [weak self] _ in
guard let self = self else { return }
self.activeDelegate?.sideMenuDidDisappear?(menu: self, animated: animated)
self.view.isHidden = true
})
}
}
override open func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// Work-around: if the menu is dismissed without animation the transition logic is never called to restore the
// the view hierarchy leaving the screen black/empty. This is because the transition moves views within a container
// view, but dismissing without animation removes the container view before the original hierarchy is restored.
// This check corrects that.
if let foundViewController = self.visibleViewController(from: presentingViewController), foundViewController.view.window == nil {
transitionController?.transition(presenting: false, animated: false)
}
// Clear selecton on UITableViewControllers when reappearing using custom transitions
if let tableViewController = topViewController as? UITableViewController,
let tableView = tableViewController.tableView,
let indexPaths = tableView.indexPathsForSelectedRows,
tableViewController.clearsSelectionOnViewWillAppear {
indexPaths.forEach { tableView.deselectRow(at: $0, animated: false) }
}
activeDelegate?.sideMenuDidDisappear?(menu: self, animated: animated)
if isBeingDismissed {
transitionController = nil
} else if dismissOnPresent {
view.isHidden = true
}
}
override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// Don't bother resizing if the view isn't visible
guard let transitionController = transitionController, !view.isHidden else { return }
rotating = true
let dismiss = self.presentingViewControllerUseSnapshot || self.dismissOnRotation
coordinator.animate(alongsideTransition: { _ in
if dismiss {
transitionController.transition(presenting: false, animated: false, complete: false)
} else {
transitionController.layout()
}
}) { [weak self] _ in
guard let self = self else { return }
if dismiss {
self.dismissMenu(animated: false)
}
self.rotating = false
}
}
open override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
transitionController?.layout()
}
override open func pushViewController(_ viewController: UIViewController, animated: Bool) {
guard viewControllers.count > 0 && pushStyle != .subMenu else {
// NOTE: pushViewController is called by init(rootViewController: UIViewController)
// so we must perform the normal super method in this case
return super.pushViewController(viewController, animated: animated)
}
let splitViewController = presentingViewController as? UISplitViewController
let tabBarController = presentingViewController as? UITabBarController
let potentialNavigationController = (splitViewController?.viewControllers.first ?? tabBarController?.selectedViewController) ?? presentingViewController
guard let navigationController = potentialNavigationController as? UINavigationController else {
return Print.warning(.cannotPush, arguments: String(describing: potentialNavigationController.self), required: true)
}
// To avoid overlapping dismiss & pop/push calls, create a transaction block where the menu
// is dismissed after showing the appropriate screen
CATransaction.begin()
defer { CATransaction.commit() }
if dismissOnPush {
let animated = animated || alwaysAnimate
if animated {
let areAnimationsEnabled = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(true)
transitionController?.transition(presenting: false, animated: animated, alongsideTransition: { [weak self] in
guard let self = self else { return }
self.activeDelegate?.sideMenuWillDisappear?(menu: self, animated: animated)
}, completion: { [weak self] _ in
guard let self = self else { return }
self.activeDelegate?.sideMenuDidDisappear?(menu: self, animated: animated)
self.dismiss(animated: false, completion: nil)
self.foundViewController = nil
})
UIView.setAnimationsEnabled(areAnimationsEnabled)
}
}
if let lastViewController = navigationController.viewControllers.last,
!allowPushOfSameClassTwice && type(of: lastViewController) == type(of: viewController) {
return
}
switch pushStyle {
case .subMenu: return // handled earlier
case .default: break
case .popWhenPossible:
for subViewController in navigationController.viewControllers.reversed() {
if type(of: subViewController) == type(of: viewController) {
navigationController.popToViewController(subViewController, animated: animated)
return
}
}
case .preserve, .preserveAndHideBackButton:
var viewControllers = navigationController.viewControllers
let filtered = viewControllers.filter { preservedViewController in type(of: preservedViewController) == type(of: viewController) }
if let preservedViewController = filtered.last {
viewControllers = viewControllers.filter { subViewController in subViewController !== preservedViewController }
if pushStyle == .preserveAndHideBackButton {
preservedViewController.navigationItem.hidesBackButton = true
}
viewControllers.append(preservedViewController)
return navigationController.setViewControllers(viewControllers, animated: animated)
}
if pushStyle == .preserveAndHideBackButton {
viewController.navigationItem.hidesBackButton = true
}
case .replace:
viewController.navigationItem.hidesBackButton = true
return navigationController.setViewControllers([viewController], animated: animated)
}
navigationController.pushViewController(viewController, animated: animated)
}
override open var transitioningDelegate: UIViewControllerTransitioningDelegate? {
get { return self }
set { Print.warning(.transitioningDelegate, required: true) }
}
}
// Interface
extension SideMenuNavigationController: MenuModel {
@IBInspectable open var allowPushOfSameClassTwice: Bool {
get { return settings.allowPushOfSameClassTwice }
set { settings.allowPushOfSameClassTwice = newValue }
}
@IBInspectable open var alwaysAnimate: Bool {
get { return settings.alwaysAnimate }
set { settings.alwaysAnimate = newValue }
}
@IBInspectable open var animationOptions: UIView.AnimationOptions {
get { return settings.animationOptions }
set { settings.animationOptions = newValue }
}
open var blurEffectStyle: UIBlurEffect.Style? {
get { return settings.blurEffectStyle }
set { settings.blurEffectStyle = newValue }
}
@IBInspectable open var completeGestureDuration: Double {
get { return settings.completeGestureDuration }
set { settings.completeGestureDuration = newValue }
}
@IBInspectable open var completionCurve: UIView.AnimationCurve {
get { return settings.completionCurve }
set { settings.completionCurve = newValue }
}
@IBInspectable open var dismissDuration: Double {
get { return settings.dismissDuration }
set { settings.dismissDuration = newValue }
}
@IBInspectable open var dismissOnPresent: Bool {
get { return settings.dismissOnPresent }
set { settings.dismissOnPresent = newValue }
}
@IBInspectable open var dismissOnPush: Bool {
get { return settings.dismissOnPush }
set { settings.dismissOnPush = newValue }
}
@IBInspectable open var dismissOnRotation: Bool {
get { return settings.dismissOnRotation }
set { settings.dismissOnRotation = newValue }
}
@IBInspectable open var dismissWhenBackgrounded: Bool {
get { return settings.dismissWhenBackgrounded }
set { settings.dismissWhenBackgrounded = newValue }
}
@IBInspectable open var enableSwipeToDismissGesture: Bool {
get { return settings.enableSwipeToDismissGesture }
set { settings.enableSwipeToDismissGesture = newValue }
}
@IBInspectable open var enableTapToDismissGesture: Bool {
get { return settings.enableTapToDismissGesture }
set { settings.enableTapToDismissGesture = newValue }
}
@IBInspectable open var initialSpringVelocity: CGFloat {
get { return settings.initialSpringVelocity }
set { settings.initialSpringVelocity = newValue }
}
/// Whether the menu appears on the right or left side of the screen. Right is the default. This property cannot be changed after the menu has loaded.
@IBInspectable open var leftSide: Bool {
get { return _leftSide.value }
set { _leftSide.value = newValue }
}
/// Indicates if the menu is anywhere in the view hierarchy, even if covered by another view controller.
open override var isHidden: Bool {
return super.isHidden
}
@IBInspectable open var menuWidth: CGFloat {
get { return settings.menuWidth }
set { settings.menuWidth = newValue }
}
@IBInspectable open var presentingViewControllerUserInteractionEnabled: Bool {
get { return settings.presentingViewControllerUserInteractionEnabled }
set { settings.presentingViewControllerUserInteractionEnabled = newValue }
}
@IBInspectable open var presentingViewControllerUseSnapshot: Bool {
get { return settings.presentingViewControllerUseSnapshot }
set { settings.presentingViewControllerUseSnapshot = newValue }
}
@IBInspectable open var presentDuration: Double {
get { return settings.presentDuration }
set { settings.presentDuration = newValue }
}
open var presentationStyle: SideMenuPresentationStyle {
get { return settings.presentationStyle }
set { settings.presentationStyle = newValue }
}
@IBInspectable open var pushStyle: SideMenuPushStyle {
get { return settings.pushStyle }
set { settings.pushStyle = newValue }
}
@IBInspectable open var statusBarEndAlpha: CGFloat {
get { return settings.statusBarEndAlpha }
set { settings.statusBarEndAlpha = newValue }
}
@IBInspectable open var usingSpringWithDamping: CGFloat {
get { return settings.usingSpringWithDamping }
set { settings.usingSpringWithDamping = newValue }
}
}
// IMPORTANT: These methods must be declared open or they will not be called.
extension SideMenuNavigationController: UIViewControllerTransitioningDelegate {
open func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transitionController = SideMenuTransitionController(
config: self,
leftSide: leftSide,
delegate: self)
return transitionController
}
open func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return transitionController
}
open func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController(using: animator)
}
open func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController(using: animator)
}
private func interactionController(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard interactive else { return nil }
interactive = false
let interactionController = SideMenuInteractionController(cancelWhenBackgrounded: dismissWhenBackgrounded, completionCurve: completionCurve)
self.interactionController = interactionController
return interactionController
}
}
extension SideMenuNavigationController: SideMenuTransitionControllerDelegate {
internal func sideMenuTransitionController(_ transitionController: SideMenuTransitionController, didDismiss viewController: UIViewController) {
sideMenuManager.sideMenuTransitionDidDismiss(menu: self)
}
internal func sideMenuTransitionController(_ transitionController: SideMenuTransitionController, didPresent viewController: UIViewController) {
removeSwipeGesture()
swipeToDismissGesture = addSwipeToDismissGesture(to: view.superview)
tapToDismissGesture = addTapToDismissGesture(to: view.superview)
}
}
internal extension SideMenuNavigationController {
func handleMenuPan(_ gesture: UIPanGestureRecognizer, _ presenting: Bool) {
let width = menuWidth
let distance = gesture.xTranslation / width
let progress = max(min(distance * factor(presenting), 1), 0)
switch (gesture.state) {
case .began:
if !presenting {
dismissMenu(interactively: true)
}
interactionController?.handle(state: .update(progress: progress))
case .changed:
interactionController?.handle(state: .update(progress: progress))
case .ended:
let velocity = gesture.xVelocity * factor(presenting)
let finished = velocity >= 100 || velocity >= -50 && abs(progress) >= 0.5
interactionController?.handle(state: finished ? .finish : .cancel)
default:
interactionController?.handle(state: .cancel)
}
}
func cancelMenuPan(_ gesture: UIPanGestureRecognizer) {
interactionController?.handle(state: .cancel)
}
func dismissMenu(animated flag: Bool = true, interactively interactive: Bool = false, completion: (() -> Void)? = nil) {
guard !isHidden else { return }
self.interactive = interactive
dismiss(animated: flag, completion: completion)
}
// Note: although this method is syntactically reversed it allows the interactive property to scoped privately
func presentFrom(_ viewControllerToPresentFrom: UIViewController?, interactively interactive: Bool, completion: (() -> Void)? = nil) {
guard let viewControllerToPresentFrom = viewControllerToPresentFrom else { return }
self.interactive = interactive
viewControllerToPresentFrom.present(self, animated: true, completion: completion)
}
}
private extension SideMenuNavigationController {
weak var activeDelegate: SideMenuNavigationControllerDelegate? {
guard !view.isHidden else { return nil }
if let sideMenuDelegate = sideMenuDelegate {
return sideMenuDelegate
}
return visibleViewController(from: presentingViewController) as? SideMenuNavigationControllerDelegate
}
func visibleViewController(from: UIViewController?) -> UIViewController? {
if let foundDelegate = foundViewController {
return foundDelegate
}
if let navigationController = from as? UINavigationController {
return visibleViewController(from: navigationController.topViewController)
}
if let tabBarController = from as? UITabBarController {
return visibleViewController(from: tabBarController.selectedViewController)
}
if let splitViewController = from as? UISplitViewController {
return visibleViewController(from: splitViewController.viewControllers.last)
}
foundViewController = from
return from
}
func setup() {
modalPresentationStyle = .overFullScreen
setupBlur()
registerForNotifications()
}
func setupBlur() {
removeBlur()
guard let blurEffectStyle = blurEffectStyle,
let view = topViewController?.view,
!UIAccessibility.isReduceTransparencyEnabled else {
return
}
originalBackgroundColor = originalBackgroundColor ?? view.backgroundColor
let blurEffect = UIBlurEffect(style: blurEffectStyle)
let blurView = UIVisualEffectView(effect: blurEffect)
view.backgroundColor = UIColor.clear
if let tableViewController = topViewController as? UITableViewController {
tableViewController.tableView.backgroundView = blurView
tableViewController.tableView.separatorEffect = UIVibrancyEffect(blurEffect: blurEffect)
tableViewController.tableView.reloadData()
} else {
blurView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
blurView.frame = view.bounds
view.insertSubview(blurView, at: 0)
}
}
func removeBlur() {
guard let originalBackgroundColor = originalBackgroundColor,
let view = topViewController?.view else {
return
}
self.originalBackgroundColor = nil
view.backgroundColor = originalBackgroundColor
if let tableViewController = topViewController as? UITableViewController {
tableViewController.tableView.backgroundView = nil
tableViewController.tableView.separatorEffect = nil
tableViewController.tableView.reloadData()
} else if let blurView = view.subviews.first as? UIVisualEffectView {
blurView.removeFromSuperview()
}
}
func removeSwipeGesture() {
if let swipeToDismissGesture = swipeToDismissGesture {
swipeToDismissGesture.view?.removeGestureRecognizer(swipeToDismissGesture)
}
}
func removeTapGesture() {
if let tapToDismissGesture = tapToDismissGesture {
tapToDismissGesture.view?.removeGestureRecognizer(tapToDismissGesture)
}
}
func registerForNotifications() {
NotificationCenter.default.removeObserver(self)
[UIApplication.willChangeStatusBarFrameNotification,
UIApplication.didEnterBackgroundNotification].forEach {
NotificationCenter.default.addObserver(self, selector: #selector(handleNotification), name: $0, object: nil)
}
}
@objc func handleNotification(notification: NSNotification) {
guard isHidden else { return }
switch notification.name {
case UIApplication.willChangeStatusBarFrameNotification:
// Dismiss for in-call status bar changes but not rotation
if !rotating {
dismissMenu()
}
case UIApplication.didEnterBackgroundNotification:
if dismissWhenBackgrounded {
dismissMenu()
}
default: break
}
}
@discardableResult func addSwipeToDismissGesture(to view: UIView?) -> UIPanGestureRecognizer? {
guard enableSwipeToDismissGesture else { return nil }
return UIPanGestureRecognizer(addTo: view, target: self, action: #selector(handleDismissMenuPan(_:)))?.with {
$0.cancelsTouchesInView = false
}
}
@discardableResult func addTapToDismissGesture(to view: UIView?) -> UITapGestureRecognizer? {
guard enableTapToDismissGesture else { return nil }
return UITapGestureRecognizer(addTo: view, target: self, action: #selector(handleDismissMenuTap(_:)))?.with {
$0.cancelsTouchesInView = false
}
}
@objc func handleDismissMenuTap(_ tap: UITapGestureRecognizer) {
let hitTest = view.window?.hitTest(tap.location(in: view.superview), with: nil)
guard hitTest == view.superview else { return }
dismissMenu()
}
@objc func handleDismissMenuPan(_ gesture: UIPanGestureRecognizer) {
handleMenuPan(gesture, false)
}
func factor(_ presenting: Bool) -> CGFloat {
return presenting ? presentFactor : hideFactor
}
var presentFactor: CGFloat {
return leftSide ? 1 : -1
}
var hideFactor: CGFloat {
return -presentFactor
}
}