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,
]
}
}
@gongzhang
Copy link

Works like magic. Saved me a day. Thanks!

@sridvijay
Copy link

Same here - thanks so much for posting this 🙏🏽

@xuao575
Copy link

xuao575 commented May 18, 2025

Incredibly useful! I’ve forked the project and added a version that can be toggled on or off with a Boolean switch.

@DabbyNdubisi
Copy link
Author

Works like magic. Saved me a day. Thanks!

I'm glad I could help :)

@DabbyNdubisi
Copy link
Author

Incredibly useful! I’ve forked the project and added a version that can be toggled on or off with a Boolean switch.

Nice!

@DabbyNdubisi
Copy link
Author

Same here - thanks so much for posting this 🙏🏽
🙏

@Rspoon3
Copy link

Rspoon3 commented Sep 23, 2025

This does not appear to be working on iOS 26.0.

@Rspoon3
Copy link

Rspoon3 commented Sep 23, 2025

Here is another bit of code that does work though.

@DabbyNdubisi
Copy link
Author

This does not appear to be working on iOS 26.0.

Yes I have this fixed in my local but hadn't pushed to the gist yet. Should work now!

@DabbyNdubisi
Copy link
Author

Here is another bit of code that does work though.

The SwiftUI direct interactive dismissal api hasn't worked in the past, but I'll check this out to see if anything has changed. For now the gist should work as before accounting for the new zoom gesture added in iOS 26

@Rspoon3
Copy link

Rspoon3 commented Sep 23, 2025

I also posted a few other solutions here.

@schthms
Copy link

schthms commented Oct 31, 2025

Works perfectly on iOS 26.0. Thank you!

@vincefried
Copy link

Works like a charm, also on iOS 26.1. Thanks!

@ramikay
Copy link

ramikay commented Dec 8, 2025

Given this uses private SPI, can anyone confirm having App Review accept an app that uses this?

@vincefried
Copy link

@ramikay It was accepted in my case.

@DabbyNdubisi
Copy link
Author

Given this uses private SPI, can anyone confirm having App Review accept an app that uses this?

Hey @ramikay, I use these across a few of my projects that are live on the app store.

This solution, while being a bit hacky doesn't abuse any private APIs per se so we should be good. The worst case scenario is Apple changes the name of their gesture recognizers which would just prevent the gestures from working again, but in that case I would notice it broken in my apps as well and provide an update to the gist :)

@ramikay
Copy link

ramikay commented Dec 9, 2025

@vincefried @DabbyNdubisi thanks for your replies!

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