From 65d57df949d1684f86bbecad599070ff9a696a21 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 19 Feb 2020 22:07:12 -0500 Subject: [PATCH] Add interacting pushing to navigation controllers Allows people to move forward in the navigation stack after popping (making popping a non-destructive action). --- Tusker.xcodeproj/project.pbxproj | 8 + .../Main/MainTabBarViewController.swift | 2 +- .../EnhancedNavigationViewController.swift | 76 ++++++++++ .../Utilities/InteractivePushTransition.swift | 139 ++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 Tusker/Screens/Utilities/EnhancedNavigationViewController.swift create mode 100644 Tusker/Screens/Utilities/InteractivePushTransition.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index c88b778a..e08e42be 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -157,6 +157,8 @@ D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; }; D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; + D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; + D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; }; D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; }; D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */; }; D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */; }; @@ -432,6 +434,8 @@ D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = ""; }; D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; + D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = ""; }; + D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = ""; }; D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = ""; }; D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSavedHashtagViewController.swift; sourceTree = ""; }; @@ -1136,6 +1140,8 @@ D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */, D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */, + D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, + D693DE5823FE24300061E07D /* InteractivePushTransition.swift */, ); path = Utilities; sourceTree = ""; @@ -1584,6 +1590,7 @@ D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, 0454DDAF22B462EF00B8BB8E /* GalleryExpandAnimationController.swift in Sources */, D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */, + D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, @@ -1698,6 +1705,7 @@ D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, + D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */, D6163F2C21AA0AF1008DAC41 /* MyProfileTableViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index 932a257a..0636d7bc 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -48,7 +48,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { if let vc = vc as? UINavigationController { return vc } else { - return UINavigationController(rootViewController: vc) + return EnhancedNavigationViewController(rootViewController: vc) } } diff --git a/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift b/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift new file mode 100644 index 00000000..c06ed147 --- /dev/null +++ b/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift @@ -0,0 +1,76 @@ +// +// EnhancedNavigationViewController.swift +// Tusker +// +// Created by Shadowfacts on 2/19/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +class EnhancedNavigationViewController: UINavigationController { + + var poppedViewControllers = [UIViewController]() + var skipResetPoppedOnNextPush = false + + private var interactivePushTransition: InteractivePushTransition! + + override func viewDidLoad() { + super.viewDidLoad() + + self.interactivePushTransition = InteractivePushTransition(navigationController: self) + } + + override func popViewController(animated: Bool) -> UIViewController? { + if let popped = super.popViewController(animated: animated) { + poppedViewControllers.insert(popped, at: 0) + return popped + } else { + return nil + } + } + + override func popToRootViewController(animated: Bool) -> [UIViewController]? { + if let popped = super.popToRootViewController(animated: animated) { + poppedViewControllers = popped + return popped + } else { + return nil + } + } + + override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { + if let popped = super.popToViewController(viewController, animated: animated) { + poppedViewControllers.insert(contentsOf: popped, at: 0) + return popped + } else { + return nil + } + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + if skipResetPoppedOnNextPush { + skipResetPoppedOnNextPush = false + } else { + self.poppedViewControllers = [] + } + super.pushViewController(viewController, animated: animated) + } + + func onWillShow() { + self.transitionCoordinator?.notifyWhenInteractionChanges({ (context) in + if context.isCancelled { + if self.interactivePushTransition.interactive { + // when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear + self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0) + } else { + // when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers), + // the popViewController(animated:) method has already been called so the VC has already been added to the popped stack + // so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck + self.poppedViewControllers.remove(at: 0) + } + } + }) + } + +} diff --git a/Tusker/Screens/Utilities/InteractivePushTransition.swift b/Tusker/Screens/Utilities/InteractivePushTransition.swift new file mode 100644 index 00000000..0a90c9a2 --- /dev/null +++ b/Tusker/Screens/Utilities/InteractivePushTransition.swift @@ -0,0 +1,139 @@ +// +// InteractivePushTransition.swift +// Tusker +// +// Created by Shadowfacts on 2/19/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +/// Allows interactively moving forward through the navigation stack after popping +/// Based on https://github.com/NSExceptional/TBInteractivePushTransition +class InteractivePushTransition: UIPercentDrivenInteractiveTransition { + + fileprivate let minimumPushVelocityThreshold: CGFloat = 700 + fileprivate let minimumPushDistanceThreshold: CGFloat = 0.5 + fileprivate let pushAnimationDuration: TimeInterval = 0.35 + + private(set) weak var navigationController: EnhancedNavigationViewController! + + private(set) weak var interactivePushGestureRecognizer: UIScreenEdgePanGestureRecognizer! + + private(set) var interactive = false + private(set) weak var pushingViewController: UIViewController? + + init(navigationController: EnhancedNavigationViewController) { + super.init() + + self.navigationController = navigationController + + let interactivePushGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:))) + self.interactivePushGestureRecognizer = interactivePushGestureRecognizer + + navigationController.delegate = self + interactivePushGestureRecognizer.edges = .right + interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!) + navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer) + } + + @objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) { + interactive = true + + let velocity = recognizer.velocity(in: recognizer.view) + let translation = recognizer.translation(in: recognizer.view) + let dx = -translation.x / navigationController.view.bounds.width + let vx = -velocity.x + + switch recognizer.state { + case .began: + if let viewController = navigationController.poppedViewControllers.first { + pushingViewController = viewController + navigationController.poppedViewControllers.removeFirst() + navigationController.skipResetPoppedOnNextPush = true + navigationController.pushViewController(viewController, animated: true) + } else { + interactive = false + } + + case .changed: + update(dx) + + case .ended: + if (dx > minimumPushDistanceThreshold || vx > minimumPushVelocityThreshold) { + finish() + } else { + cancel() + } + interactive = false + + default: + cancel() + interactive = false + } + } + +} + +extension InteractivePushTransition: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + self.navigationController.onWillShow() + } + + func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + if operation == .push, interactive { + return self + } + + return nil + } + + func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + if interactive { + return self + } + return nil + } +} + +extension InteractivePushTransition: UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return pushAnimationDuration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let toView = transitionContext.view(forKey: .to)! + let fromView = transitionContext.view(forKey: .from)! + + let dimmingView = UIView() + dimmingView.backgroundColor = .black + dimmingView.alpha = 0 + dimmingView.frame = fromView.frame + + transitionContext.containerView.addSubview(dimmingView) + transitionContext.containerView.addSubview(toView) + + // modify frame of presented view to go from x = to x = 0 + var frame = fromView.frame + frame.origin.x = frame.width + toView.frame = frame + frame.origin.x = 0 + + // we want it linear while interactive, but we want the "ease out" animation + // if the user flicks the screen ahrd enough to finish the transition without interaction + let options = interactive ? UIView.AnimationOptions.curveLinear : .curveEaseOut + + let duration = transitionDuration(using: transitionContext) + UIView.animate(withDuration: duration, delay: 0, options: options, animations: { + // these magic numbers scientifically determined by repeatedly adjusting and comparing to the system animation + let translationDistance = -frame.size.width * 0.3 + fromView.transform = CGAffineTransform(translationX: translationDistance, y: 0) + toView.frame = frame + dimmingView.alpha = 0.075 + }) { (finished) in + fromView.transform = .identity + dimmingView.removeFromSuperview() + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + } +}