Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save DabbyNdubisi/c4045a0231435c22be887cb6d9109507 to your computer and use it in GitHub Desktop.

Select an option

Save DabbyNdubisi/c4045a0231435c22be887cb6d9109507 to your computer and use it in GitHub Desktop.
Control Interactive Dismissal of Navigation Zoom Transition SwiftUI
import SwiftUI
import UIKit
import Foundation
// MARK: - AllowedNavigationDismissalGestures
public struct AllowedNavigationDismissalGestures: OptionSet, Sendable {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
public static let none: AllowedNavigationDismissalGestures = []
/// Default behaviour
public static let all: AllowedNavigationDismissalGestures = [.swipeToGoBack, .zoomTransitionGesturesOnly]
/// Includes both regular left-right swipe to go back and edge-pan for zoom transition dismisall
public static let edgePanGesturesOnly: AllowedNavigationDismissalGestures = [.swipeToGoBack, .zoomEdgePanToDismiss]
/// Includes all zoom transition gestures: edge-pan, swipe-down, pinch
public static let zoomTransitionGesturesOnly: AllowedNavigationDismissalGestures = [.zoomEdgePanToDismiss, .zoomSwipeDownToDismiss, .zoomPinchToDismiss]
public static let swipeToGoBack = AllowedNavigationDismissalGestures(rawValue: 1 << 0)
public static let zoomEdgePanToDismiss = AllowedNavigationDismissalGestures(rawValue: 1 << 1)
public static let zoomSwipeDownToDismiss = AllowedNavigationDismissalGestures(rawValue: 1 << 2)
public static let zoomPinchToDismiss = AllowedNavigationDismissalGestures(rawValue: 1 << 3)
}
public extension View {
func navigationAllowDismissalGestures(_ gestures: AllowedNavigationDismissalGestures = .all) -> some View {
modifier(NavigationAllowedDismissalGesturesModifier(allowedDismissalGestures: gestures))
}
}
// MARK: - NavigationAllowedDismissalGesturesModifier
private struct NavigationAllowedDismissalGesturesModifier: ViewModifier {
var allowedDismissalGestures: AllowedNavigationDismissalGestures
func body(content: Content) -> some View {
content
.background(
NavigationDismissalGestureUpdater(allowedDismissalGestures: allowedDismissalGestures)
.frame(width: .zero, height: .zero)
)
}
}
// MARK: - NavigationDismissalGestureUpdater
private struct NavigationDismissalGestureUpdater: UIViewControllerRepresentable {
@State private var viewMountRetryCount = 0
var allowedDismissalGestures: AllowedNavigationDismissalGestures
func makeUIViewController(context: Context) -> UIViewController { .init() }
func updateUIViewController(_ viewController: UIViewController, context: Context) {
Task { @MainActor in
guard
let parentVC = viewController.parent,
let navigationController = parentVC.navigationController
else {
// updateUIViewController could get called a bit too early
// before the view heirarchy has been fully setup
if viewMountRetryCount < Constants.maxRetryCountForNavigationHeirarchy {
viewMountRetryCount += 1
try await Task.sleep(for: .milliseconds(100))
return updateUIViewController(viewController, context: context)
} else {
// unable to find navigation controller
return
}
}
guard navigationController.topViewController == parentVC else {
return
}
navigationController.interactivePopGestureRecognizer?.isEnabled = allowedDismissalGestures.contains(.swipeToGoBack)
let viewLevelGestures = parentVC.view.gestureRecognizers ?? []
for gesture in viewLevelGestures {
switch String(describing: type(of: gesture)) {
case Constants.zoomEdgePanToDismissClassType:
gesture.isEnabled = allowedDismissalGestures.contains(.zoomEdgePanToDismiss)
case Constants.zoomSwipeDownToDismissClassType:
gesture.isEnabled = allowedDismissalGestures.contains(.zoomSwipeDownToDismiss)
case Constants.zoomPinchToDismissClassType:
gesture.isEnabled = allowedDismissalGestures.contains(.zoomPinchToDismiss)
default:
continue
}
}
}
}
static func dismantleUIViewController(_ viewController: UIViewController, coordinator: Coordinator) {
viewController.parent?.navigationController?.interactivePopGestureRecognizer?.isEnabled = true
(viewController.parent?.view.gestureRecognizers ?? []).forEach({ gesture in
if Constants.navigationZoomGestureTypeClasses.contains(String(describing: type(of: gesture))) {
gesture.isEnabled = true
}
})
}
// MARK: Private
private enum Constants {
static let maxRetryCountForNavigationHeirarchy = 2
// These are private Navigation related UIKit gesture recognizers that we want to disable
// when the swipe to go back is disabled.
static let zoomEdgePanToDismissClassType: String = "_UIParallaxTransitionPanGestureRecognizer" // Edge pan zoom transition dismissal gesture
static let zoomSwipeDownToDismissClassType: String = {
// Swipe down to dismiss gesture
if #available(iOS 26, *) {
"_UIContentSwipeDismissGestureRecognizer"
} else {
"_UISwipeDownGestureRecognizer"
}
}()
static let zoomPinchToDismissClassType: String = "_UITransformGestureRecognizer" // Pinch to dismiss gesture
static let navigationZoomGestureTypeClasses: Set<String> = [
zoomEdgePanToDismissClassType,
zoomSwipeDownToDismissClassType,
zoomPinchToDismissClassType,
]
}
}
@ramikay
Copy link

ramikay commented Dec 9, 2025

@vincefried @DabbyNdubisi thanks for your replies!

@bobosheep
Copy link

This solution saved me — it allows me to keep Apple’s native smooth animations while preserving our app’s design.

However, it breaks in the following scenario:
If the design does not allow pinch-to-dismiss but allows swipe-to-dismiss, when the user swipes down slightly and releases (staying on the current page), they can then use pinch to dismiss the page.

If anyone encounters the same issue, instead of disabling the gesture, remove the gesture recognizer directly from the parent view.

Like this:

if !allowedDismissalGestures.contains(/* Your allowed gesture */) {
    parentVC.view?.removeGestureRecognizer(gesture)
}

Hope this helps anyone facing the same issue.

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