Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save DabbyNdubisi/c4045a0231435c22be887cb6d9109507 to your computer and use it in GitHub Desktop.
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.

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