avatarTan Yun Ching

Summary

The article discusses how to restore the missing back swipe gesture in SwiftUI applications when using a custom back button.

Abstract

The article addresses a common issue faced by SwiftUI developers where the default back swipe gesture does not work when a custom back button is implemented. The author explains the importance of this gesture for one-handed iPhone use and outlines a step-by-step solution to reintroduce the functionality. This involves creating a UIScreenEdgePanGestureRecognizer, attaching it to a UINavigationController, and encapsulating the solution within a custom NavigationStack. The author also provides code snippets and discusses the rationale behind the need for this solution, considering the absence of a bottom back button option in iOS, unlike Android.

Opinions

  • The author expresses personal frustration with the lack of a bottom '<' back button option in iOS, which is more accessible for thumb reach, especially on larger screens.
  • The author suggests that the absence of the back swipe gesture in apps like Grab is perplexing and implies that it might not be an intentional design choice.
  • The author values the convenience of the swipe back gesture for one-handed iPhone use and believes that its absence in SwiftUI with custom back buttons is a significant oversight.
  • The author is open to hearing from others about why this feature might be intentionally omitted in some iOS apps.
  • The author endorses an AI service as a cost-effective alternative to ChatGPT Plus (GPT-4), indicating a preference for efficient and economical tools in the realm of AI and development.

Back Swipe Gesture Missing When Using SwiftUI Custom Back Button?

In UIKit, the interactivePopGestureRecognizer 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):

Code — Default iOS behaviour
Showcase — Default iOS behaviour

Example (Custom Back Button):

Code — after added custom back button

When you run and try swipe back, it ❌does not work.

Showcase — Swipe Back Gesture Failed to 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+ or NavigationView 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

UIScreenEdgePanGestureRecognizer — interactivePopGestureRecognizer

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.

View hierarchy

UIView extension to find its parent UIViewController from a child view

Extension — parentViewController
  1. Creates a sequence starting with the current view (self) and generating subsequent elements by calling the next property of each element. The next 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

UIViewRepresentable - AttachPopGestureView
  • 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 the gesture from the CustomNavigationStack with this view, allowing it to be updated.
Extension — addInteractivePopGesture
  • addInteractivePopGesture(_:)is an extension for UINavigationController. 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

View — CustomNavigationStack
  • NavigationStack(available on iOS 17.0 and later) is interchangeable with NavigationView 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!

Photo by Jessica Mangano on Unsplash
Swiftui
iOS
Recommended from ReadMedium