Instantly share code, notes, and snippets.
Last active
January 11, 2026 19:27
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save kopyl/b2bdad6b07249b3fb773662005ed1b4b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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