Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save eoghain/7e9afdd43d1357fb8824126e0cbd491d to your computer and use it in GitHub Desktop.

Select an option

Save eoghain/7e9afdd43d1357fb8824126e0cbd491d to your computer and use it in GitHub Desktop.
UINavigationController that implements swipe to push/pop in an interactive animation. Just implement the InteractiveNavigation protocol on your ViewControllers you add to the nav stack to get custom transitions. Or implement a single animation and return it instead of the nil's in the UIViewControllerTransitioningDelegate and all transitions wil…
import UIKit
protocol InteractiveNavigation {
var presentAnimation: UIViewControllerAnimatedTransitioning? { get }
var dismissAnimation: UIViewControllerAnimatedTransitioning? { get }
func showNext()
}
enum SwipeDirection: CGFloat, CustomStringConvertible {
case left = -1.0
case none = 0.0
case right = 1.0
var description: String {
switch self {
case .left: return "Left"
case .none: return "None"
case .right: return "Right"
}
}
}
class CustomInteractiveAnimationNavigationController: UINavigationController , UIViewControllerTransitioningDelegate, UINavigationControllerDelegate {
// MARK: - Properties
var interactionController: UIPercentDrivenInteractiveTransition?
// MARK: - View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
transitioningDelegate = self // for presenting the original navigation controller
delegate = self // for navigation controller custom transitions
// Choose one stlye of gesture recognizer
// Pan Gesture (swipe from/to anywhere on the screen)
let pan = UIPanGestureRecognizer(target: self, action: #selector(CustomInteractiveAnimationNavigationController.handlePan(_:)))
view.addGestureRecognizer(pan)
// Edge Pan Gestures (swipe only from either edge)
let left = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(CustomInteractiveAnimationNavigationController.handleSwipeFromLeft(_:)))
left.edges = .left
view.addGestureRecognizer(left);
let right = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(CustomInteractiveAnimationNavigationController.handleSwipeFromRight(_:)))
right.edges = .right
view.addGestureRecognizer(right);
}
// MARK: - Gesture Handlers
func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let gestureView = gesture.view else {
return
}
let flickThreshold: CGFloat = 700.0 // Speed to make transition complete
let distanceThreshold: CGFloat = 0.3 // Distance to make transition complete
let velocity = gesture.velocity(in: gestureView)
let translation = gesture.translation(in: gestureView)
let percent = fabs(translation.x / gestureView.bounds.size.width);
let swipeDirection: SwipeDirection = (velocity.x > 0) ? .right : .left
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
if swipeDirection == .right {
if viewControllers.count > 1 {
popViewController(animated: true)
} else {
dismiss(animated: true, completion: nil)
}
}
else {
if let currentViewController = viewControllers.last as? InteractiveNavigation {
currentViewController.showNext()
}
}
case .changed:
if let interactionController = self.interactionController {
interactionController.update(percent)
}
case .cancelled:
if let interactionController = self.interactionController {
interactionController.cancel()
}
case .ended:
if let interactionController = self.interactionController {
if abs(percent) > distanceThreshold || abs(velocity.x) > flickThreshold {
interactionController.finish()
} else {
interactionController.cancel()
}
self.interactionController = nil
swipeDirection = .none
}
default:
break
}
}
func handleSwipeFromLeft(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard let gestureView = gesture.view else {
return
}
let percent = gesture.translation(in: gestureView).x / gestureView.bounds.size.width
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
if viewControllers.count > 1 {
popViewController(animated: true)
} else {
dismiss(animated: true, completion: nil)
}
case .changed:
if let interactionController = self.interactionController {
interactionController.update(percent)
}
case .cancelled:
if let interactionController = self.interactionController {
interactionController.cancel()
}
case .ended:
if let interactionController = self.interactionController {
if percent > 0.5 {
interactionController.finish()
} else {
interactionController.cancel()
}
self.interactionController = nil
}
default:
break
}
}
func handleSwipeFromRight(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard let gestureView = gesture.view else {
return
}
let percent = -gesture.translation(in: gestureView).x / gestureView.bounds.size.width
switch gesture.state {
case .began:
if let currentViewController = viewControllers.last as? InteractiveNavigation {
interactionController = UIPercentDrivenInteractiveTransition()
currentViewController.showNext()
}
case .changed:
if let interactionController = self.interactionController {
interactionController.update(percent)
}
case .cancelled:
if let interactionController = self.interactionController {
interactionController.cancel()
}
case .ended:
if let interactionController = self.interactionController {
if percent > 0.5 {
interactionController.finish()
} else {
interactionController.cancel()
}
self.interactionController = nil
}
default:
break
}
}
// MARK: - UIViewControllerTransitioningDelegate
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard let _ = presenting as? InteractiveNavigation else {
return nil
}
if let currentViewController = viewControllers.last as? InteractiveNavigation {
return currentViewController.presentAnimation
}
return nil
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard viewControllers.count != 1 else {
return nil
}
if let currentViewController = viewControllers.last as? InteractiveNavigation {
return currentViewController.dismissAnimation
}
return nil
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
// MARK: - UINavigationControllerDelegate
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard operation != .none else {
return nil
}
var animation: UIViewControllerAnimatedTransitioning = nil
if let currentViewController = viewControllers.last as? InteractiveNavigation {
if operation == .push {
animation = currentViewController.presentAnimation
}
else if operation == .pop {
animation = currentViewController.dismissAnimation
}
}
return animation
}
func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
}
class TutorialAnimation: NSObject, UIViewControllerAnimatedTransitioning {
// MARK: - Animations
// Basic push in animation, override for specifics
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) as? TutorialViewController,
let toVC = transitionContext.viewController(forKey: .to) as? TutorialViewController else {
return transitionContext.completeTransition(false)
}
let container = transitionContext.containerView
// Force views to layout
toVC.view.setNeedsLayout()
toVC.view.layoutIfNeeded()
fromVC.view.setNeedsLayout()
fromVC.view.layoutIfNeeded()
// Transformations
let distance = container.frame.width
let offScreenRight = CGAffineTransform(translationX: distance, y: 0)
let offScreenLeft = CGAffineTransform(translationX: -distance, y: 0)
var toStartTransform = offScreenRight
var fromEndTransform = offScreenLeft
if toVC.pageIndex < fromVC.pageIndex {
toStartTransform = offScreenLeft
fromEndTransform = offScreenRight
}
toVC.view.transform = toStartTransform
// add views to our view controller
container.addSubview(fromVC.view)
container.addSubview(toVC.view)
// get the duration of the animation
let duration = transitionDuration(using: transitionContext)
// perform the animation!
UIView.animate(withDuration: duration, animations: {
toVC.view.transform = .identity
fromVC.view.transform = fromEndTransform
}, completion: { _ in
if transitionContext.transitionWasCancelled {
toVC.view.removeFromSuperview()
} else {
fromVC.view.removeFromSuperview()
}
transitionContext.completeTransition(transitionContext.transitionWasCancelled == false)
})
}
// return how many seconds the transiton animation will take
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
// Helper method to generate transform between 2 rects
func transform(from: CGRect, toRect to: CGRect, keepAspectRatio: Bool) -> CGAffineTransform {
var transform = CGAffineTransform.identity
let xOffset = to.midX-from.midX
let yOffset = to.midY-from.midY
transform = transform.translatedBy(x: xOffset, y: yOffset)
if keepAspectRatio {
let fromAspectRatio = from.size.width/from.size.height
let toAspectRatio = to.size.width/to.size.height
if fromAspectRatio > toAspectRatio {
transform = transform.scaledBy(x: to.size.height/from.size.height, y: to.size.height/from.size.height)
} else {
transform = transform.scaledBy(x: to.size.width/from.size.width, y: to.size.width/from.size.width)
}
} else {
transform = transform.scaledBy(x: to.size.width/from.size.width, y: to.size.height/from.size.height)
}
return transform
}
#if DEBUG
// MARK: - Debugging
private var debugView = [UIView]()
private var displayLink: CADisplayLink?
@objc func animationDidUpdate(displayLink: CADisplayLink) {
debugView.forEach { (view) in
if let presentationLayer = view.layer.presentation() {
print("🎦 \(view)\n👉 currentPosition: (midX: \(presentationLayer.frame.midX), midY: \(presentationLayer.frame.midY))\n👉 currentSize: \(presentationLayer.frame.size)")
}
}
}
func debug(_ view: UIView) {
debugView.append(view)
}
func startDebugging() {
let displayLink = CADisplayLink(target: self, selector: #selector(animationDidUpdate))
if #available(iOS 10.0, *) {
displayLink.preferredFramesPerSecond = 60
} else {
displayLink.frameInterval = 1
}
displayLink.add(to: RunLoop.main, forMode: RunLoop.Mode.default)
}
func stopDebugging() {
debugView.removeAll()
displayLink?.remove(from: RunLoop.main, forMode: RunLoop.Mode.default)
}
#endif
}
class TutorialViewController: UIViewController {
// MARK: - Properties
var pageIndex: Int = 0
var presentAnimation: UIViewControllerAnimatedTransitioning? = TutorialAnimation()
var dismissAnimation: UIViewControllerAnimatedTransitioning? = TutorialAnimation()
// MARK: IBOutlets
@IBOutlet weak var pageControl: UIPageControl!
@IBOutlet weak var backgroundImage: UIView!
// MARK: - Initialization
override func awakeFromNib() {
super.awakeFromNib()
setup()
}
func setup() {
// Override me to set pageIndex, prevAnimationCoordinator, and nextAnimationCoordiantor
}
// MARK: - View Lifecycle
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.pageControl.currentPage = pageIndex
// Hack to fix rotation issues
self.rotateTopView(view: view)
}
// MARK: - Navigation
func showNext() {
performSegue(withIdentifier: "next", sender: self)
}
func showPrevious() {
performSegue(withIdentifier: "unwind", sender: self)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "next" {
if let destinationVC = segue.destination as? TutorialViewController {
destinationVC.delegate = delegate
}
}
super.prepare(for: segue, sender: sender)
}
// MARK: - IBActions
@IBAction func changePage(_ sender: UIPageControl) {
let index = sender.currentPage
if index <= self.pageIndex {
showPrevious()
} else {
showNext()
}
}
@IBAction func unwindToPreviousTutorial(_ sender: UIStoryboardSegue) {
}
}
// MARK: -
// HACK to fix rotation with custom animations
// https://forums.developer.apple.com/thread/11612
// Call in viewWillAppear of affected view controllers
extension UIViewController {
func rotateTopView(view:UIView) {
if let superview = view.superview {
rotateTopView(view: superview)
} else {
view.frame = UIWindow().frame
}
}
}
@eoghain
Copy link
Author

eoghain commented Feb 27, 2020

@MiteshiOS The CustomInteractiveAnimationNavigationController will call either the showPrevious or showNext methods when the user swipes. So you'll need to implement those methods to do the correct pushing/popping of the viewControllers. I used Segues because when I build this it was easiest to setup all of my screens in IB and just link them all together via segues. You should be able to do programatic push/pop calls just like any other NavigationController in those methods.

@MiteshiOS
Copy link

MiteshiOS commented Feb 28, 2020

@ eoghain Ok thanks for the information. Just let me know is there any way so i can derived showPrevious or showNext methods into my root view controller ?

Copy link

ghost commented Nov 13, 2020

@eoghain this works really well, thank you.
However, if you have say a UITableView on the underlying view controller, then cell row swipes will not work. Ideally you want the cell swipe (actually a pan) to be recognized and handled before the nav controller. I've experimented with shouldBeRequiredtoFailBy etc. but without luck. Thoughts on how to support this?

@kopyl
Copy link

kopyl commented Dec 16, 2025

I don't like that you can't drag the last view controller when you swipe to pop it :(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment