Skip to content

Instantly share code, notes, and snippets.

@kopyl
Last active January 11, 2026 19:27
Show Gist options
  • Select an option

  • Save kopyl/b2bdad6b07249b3fb773662005ed1b4b to your computer and use it in GitHub Desktop.

Select an option

Save kopyl/b2bdad6b07249b3fb773662005ed1b4b to your computer and use it in GitHub Desktop.
import UIKit
class SwipeNavigationController: UINavigationController {
/// Make sure you set CADisableMinimumFrameDurationOnPhone to true in Info.plist
/// Otherwise the smoothness won't be that good as with "targets" approach:
/// https://gist.github.com/kopyl/78600e5a9e0865dac02fdbd7d845139b
private var isInteractiveTransition = false
private var transitionFromVC: UIViewController?
private var transitionToVC: UIViewController?
private var transitionContainerView: UIView?
private var frameRateLink: CADisplayLink?
override func viewDidLoad() {
super.viewDidLoad()
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGesture.delegate = self
view.addGestureRecognizer(panGesture)
interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
let progress = max(0, min(1, translation.x / view.bounds.width))
switch gesture.state {
case .began:
beginInteractiveTransition()
case .changed:
updateInteractiveTransition(progress: progress)
case .ended, .cancelled:
let velocity = gesture.velocity(in: view).x
let shouldComplete = progress > 0.2 || velocity > 800
endInteractiveTransition(progress: progress, velocity: velocity, shouldComplete: shouldComplete)
default:
cancelTransition()
}
}
private func beginInteractiveTransition() {
guard viewControllers.count > 1,
let fromVC = viewControllers.last,
let toVC = viewControllers.dropLast().last,
let containerView = fromVC.view.superview else { return }
isInteractiveTransition = true
transitionFromVC = fromVC
transitionToVC = toVC
transitionContainerView = containerView
startFrameRateKeepAlive()
toVC.view.removeFromSuperview()
toVC.view.frame = CGRect(x: -containerView.bounds.width * 0.3, y: 0, width: containerView.bounds.width, height: containerView.bounds.height)
containerView.insertSubview(toVC.view, belowSubview: fromVC.view)
fromVC.beginAppearanceTransition(false, animated: true)
toVC.beginAppearanceTransition(true, animated: true)
}
private func updateInteractiveTransition(progress: CGFloat) {
guard isInteractiveTransition,
let fromVC = transitionFromVC,
let toVC = transitionToVC,
let containerView = transitionContainerView else { return }
let width = containerView.bounds.width
let height = containerView.bounds.height
fromVC.view.frame = CGRect(x: progress * width, y: 0, width: width, height: height)
toVC.view.frame = CGRect(x: (progress - 1.0) * width * 0.3, y: 0, width: width, height: height)
}
private func endInteractiveTransition(progress: CGFloat, velocity: CGFloat, shouldComplete: Bool) {
guard isInteractiveTransition,
let fromVC = transitionFromVC,
let toVC = transitionToVC,
let containerView = transitionContainerView else { return }
let width = containerView.bounds.width
let height = containerView.bounds.height
if shouldComplete {
let remainingDistance = (1 - progress) * width
let duration = abs(velocity) < 1 ? 0.35 : Double(min(0.35, max(0.1, remainingDistance / abs(velocity))))
UIView.animate(withDuration: duration, delay: 0, options: .curveEaseOut) {
fromVC.view.frame = CGRect(x: width, y: 0, width: width, height: height)
toVC.view.frame = containerView.bounds
} completion: { _ in
self.completeTransition()
}
} else {
let duration = Double(min(0.35, max(0.1, progress * width / max(abs(velocity), 300))))
UIView.animate(withDuration: duration, delay: 0, options: .curveEaseOut) {
fromVC.view.frame = containerView.bounds
toVC.view.frame = CGRect(x: -width * 0.3, y: 0, width: width, height: height)
} completion: { _ in
self.cancelTransition()
}
}
}
private func startFrameRateKeepAlive() {
frameRateLink?.invalidate()
let link = CADisplayLink(target: self, selector: #selector(frameRateLinkFired))
if #available(iOS 15.0, *) {
let maxFps = Float(UIScreen.main.maximumFramesPerSecond)
link.preferredFrameRateRange = CAFrameRateRange(minimum: maxFps, maximum: maxFps, preferred: maxFps)
}
link.add(to: .main, forMode: .common)
frameRateLink = link
}
private func stopFrameRateKeepAlive() {
frameRateLink?.invalidate()
frameRateLink = nil
}
@objc private func frameRateLinkFired(_ link: CADisplayLink) {}
private func completeTransition() {
guard let fromVC = transitionFromVC,
let toVC = transitionToVC,
let containerView = transitionContainerView else {
cleanupTransition()
return
}
fromVC.view.removeFromSuperview()
toVC.view.frame = containerView.bounds
fromVC.endAppearanceTransition()
toVC.endAppearanceTransition()
popViewController(animated: false)
cleanupTransition()
}
private func cancelTransition() {
guard let fromVC = transitionFromVC,
let toVC = transitionToVC,
let containerView = transitionContainerView else {
cleanupTransition()
return
}
fromVC.view.frame = containerView.bounds
toVC.view.removeFromSuperview()
fromVC.beginAppearanceTransition(true, animated: true)
fromVC.endAppearanceTransition()
toVC.beginAppearanceTransition(false, animated: true)
toVC.endAppearanceTransition()
cleanupTransition()
}
private func cleanupTransition() {
stopFrameRateKeepAlive()
isInteractiveTransition = false
transitionFromVC = nil
transitionToVC = nil
transitionContainerView = nil
}
}
extension SwipeNavigationController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard viewControllers.count > 1,
let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
let velocity = pan.velocity(in: view)
let isHorizontalSwipeToRight = velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
return isHorizontalSwipeToRight
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return otherGestureRecognizer is UIPanGestureRecognizer
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment