Skip to content

Instantly share code, notes, and snippets.

@eoghain
Last active September 21, 2023 07:33
Show Gist options
  • 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
}
}
}
@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