Back Swipe Gesture Missing When Using SwiftUI Custom Back Button?
In UIKit, the interactivePopGestureRecogni
zer property within UINavigationController
enables the configuration for popping the top view controller off the navigation stack through a swipe gesture.
As a long-time iPhone user, I have become accustomed to utilizing swipe gestures extensively, particularly when I am operating my phone with one hand. However, the swipe back gesture, used for navigating back in a SwiftUI-based app, isn’t working as expected when using a custom back button.
Example (Default iOS Behaviour):
Example (Custom Back Button):
When you run and try swipe back, it ❌does not work.
Solution — Adding Back Swipe Functionality
Let’s break down the problem into smaller subtasks and address them sequentially.
- Creating a UIScreenEdgePanGestureRecognizer: As per Apple’s documentation, the
UIScreenEdgePanGestureRecognizer
is a continuous gesture recognizer designed to interprets panning gestures that start near an edge of the screen. This behavior aligns with the default iOS swipe action. - Attaching the Gesture to a View: The challenging aspect here is to attach the gesture to the
UINavigationController
instead of one of its child view. This step is crucial for achieving the desired behavior. - Create a custom NavigationStack: Develop a custom SwiftUI view that takes generic content and wraps it in a
NavigationStack
(available in iOS 17.0+ orNavigationView
for earlier iOS versions). It addresses the swipe back issue by integrating a custom gesture recognizer into the navigation stack. - (Optional) Dynamically Disabling the Gesture: This step provides the flexibility to dynamically disable the gesture, which can be valuable in scenarios where you have a modal view or need to implement a custom back gesture applicable only to specific views. — Please refer to my following article🫡
Creating a UIScreenEdgePanGestureRecognizer
Create a state property @State private var interactivePopGestureRecognizer
holds a UIScreenEdgePanGestureRecognizer
, which is a type of gesture recognizer. It's initialized with certain settings.
gesture.name
assigns a unique name to the gesture recognizer. This unique name is to help us to check if there are any gesture added already. To prevent duplication.gesture.edges
specifies that the gesture should recognize edge panning on the left side of the screen.gesture.isEnabled
sets the recognizer to be initially enabled.
Attaching the Gesture to a View
In this step, the objective is to attach the gesture to the Navigation Controller. To accomplish this, it is essential to locate the Navigation Controller from one of its child views.
UIView extension to find its parent UIViewController from a child view
- Creates a sequence starting with the current view (
self
) and generating subsequent elements by calling thenext
property of each element. Thenext
property typically returns the next responder in the responder chain, which includes views and view controllers.
2. Find the first element that is an instance of UIViewController
using first(where: { $0 is UIViewController }) as? UIViewController
AttachPopGestureView
acts as a bridge between SwiftUI and UIKit to add the custom gesture recognizer to the view hierarchy.@Binding var gesture
: This binding property connects thegesture
from theCustomNavigationStack
with this view, allowing it to be updated.
addInteractivePopGesture(_:)
is an extension forUINavigationController
. It retrieves the target for the interactive pop gesture and assigns it to the custom gesture. Then, it adds the custom gesture to the navigation controller's view.
Thanks to https://stackoverflow.com/a/60526328
Create a custom NavigationStack
NavigationStack
(available on iOS 17.0 and later) is interchangeable withNavigationView
based on your preference and the supported iOS version.
👩🏽💻Code
struct CustomNavigationStack<Content: View>: View {
@ViewBuilder var content: Content
@State private var interactivePopGestureRecognizer: UIScreenEdgePanGestureRecognizer = {
let gesture = UIScreenEdgePanGestureRecognizer()
gesture.name = UUID().uuidString
gesture.edges = UIRectEdge.left
gesture.isEnabled = true
return gesture
}()
var body: some View {
NavigationStack {
content
.background {
AttachPopGestureView(gesture: $interactivePopGestureRecognizer)
}
}
}
}
struct AttachPopGestureView: UIViewRepresentable {
@Binding var gesture: UIScreenEdgePanGestureRecognizer
func makeUIView(context: Context) -> some UIView {
return UIView()
}
func updateUIView(_ uiView: UIViewType, context: Context) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
if let parentVC = uiView.parentViewController {
if let navigationController = parentVC.navigationController {
// To prevent duplication
guard !(navigationController.view.gestureRecognizers?
.contains(where: {$0.name == gesture.name}) ?? true) else { return }
navigationController.addInteractivePopGesture(gesture)
}
}
}
}
}
//MARK: - Helper
fileprivate extension UINavigationController {
func addInteractivePopGesture(_ gesture: UIPanGestureRecognizer) {
guard let gestureSelector = interactivePopGestureRecognizer?.value(forKey: "targets") else { return }
gesture.setValue(gestureSelector, forKey: "targets")
view.addGestureRecognizer(gesture)
}
}
extension UIView {
var parentViewController: UIViewController? {
sequence(first: self) { $0.next }.first(where: { $0 is UIViewController }) as? UIViewController
}
}
Usage
@main
struct TestApp: App {
var body: some Scene {
WindowGroup {
CustomNavigationStack {
NavigationLink("Go Page 2") {
PageTwo()
}
}
}
}
}
struct PageTwo: View {
@Environment (\.dismiss) var dismiss
var body: some View {
Text("Page 2")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.yellow)
// CUSTOM BACK BUTTON ADDED
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
self.dismiss()
} label: {
Image(systemName: "chevron.left")
}
}
}
}
}
Thanks all, happy reading! 😄
I first came up with the idea of writing this article because that many iOS apps, such as Grab, do not incorporate this feature, which I find somewhat perplexing.
This becomes particularly challenging when dealing with larger screens, as reaching for the ‘<’ button in the top left corner of the app can be cumbersome, even for individuals with larger hands.
In contrast to Android, iOS does not offer the option of a selectable bottom ‘<’ button, which would be more accessible for my thumb.
If this is intentional and you are aware of the reasons behind it, I would love to listen to your opinion here.
Anyway thanks for reading!