Skip to content

Instantly share code, notes, and snippets.

@eoghain
Last active September 21, 2023 07:33
Show Gist options
  • Star 33 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save eoghain/7e9afdd43d1357fb8824126e0cbd491d to your computer and use it in GitHub Desktop.
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
}
}
}
@thuongvanbui39
Copy link

Does showNext() function have popViewController function?

@Immortalpk
Copy link

While using this it's giving me crash that presentAnimation is nil.

Screenshot 2019-08-27 at 9 00 17 AM

How Can i solve this issue, Please help me

@eoghain
Copy link
Author

eoghain commented Aug 28, 2019

My code doesn't have this line animation = currentViewController.presentAnimation!. So I'm guessing that is code that you wrote, and the problem that you are having is that you are doing a force unwrap on presentAnimation and in your currentViewController its nil. The ! (force unwrap) operator in Swift is very dangerous and will cause this crash if the property you are unwrapping is actually nil.

@MrSkwiggs
Copy link

Could you provide an example of how a ViewController would implement the InteractiveNavigation protocol ?

@eoghain
Copy link
Author

eoghain commented Dec 16, 2019

Just added TutorialViewController.swift and TutorialAnimation.swift files to show how I've used this. No guarantee that they work since they are just copy/pasted from a project I implemented them in.

@MrSkwiggs
Copy link

@eoghain awesome, thanks a lot !

@MiteshiOS
Copy link

I just set my root view controller on CustomInteractiveAnimationNavigationController. So now can you please let me know, on root view controller how can i push my second with just using swipe. I don't want to use segue or button click to go into next view controller ?

@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 ?

@Chivs
Copy link

Chivs 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?

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