avatarTan Yun Ching

Summary

This article builds upon a previous one, providing a detailed guide on how to dynamically enable or disable the back swipe gesture in a SwiftUI-based application.

Abstract

The article is the second part of a series addressing the missing back swipe gesture issue in SwiftUI applications. It introduces a CustomNavigationStack view that includes a custom gesture recognizer to facilitate swipe-back navigation. The author explains the usage of a toggle to control the swipe gesture's state and describes a solution involving an InterPopGestureModifier to manage gesture modifications. The solution includes handling gesture modifications with a view modifier, attaching a listener to the CustomNavigationStack, and performing safety checks to verify the gesture name. The article also discusses the creation of an environment key for gesture identification and wraps up with a view extension to disable the interactive pop gesture. The code examples provided demonstrate how to implement these solutions, ensuring that the back swipe gesture can be controlled dynamically within the application.

Opinions

  • The author encourages readers to read the previous installment for a comprehensive understanding of the issue.
  • The author suggests that the method of passing the gesture name to the environment is currently effective but is open to alternative solutions.
  • The use of the onChange modifier and NotificationCenter for updating the gesture's state is highlighted as an efficient approach.
  • The author emphasizes the importance of safety checks to prevent unexpected issues, particularly when the gesture name might not be available.
  • The article concludes with a call for readers to share any alternative solutions they might have, indicating a collaborative and open-minded approach to problem-solving.

SwiftUI: Disable Back Swipe Gesture Dynamically

Welcome to part 2 of my series on addressing the issue of the Back Swipe Gesture Missing When Using SwiftUI. If you haven’t had the chance to read the previous installment, I encourage you to do so.

In the preceding article, I presented a solution to a problem in which the swipe back gesture, designed for navigating back within a SwiftUI-based application, was not working as intended. The code introduced a CustomNavigationStack view that incorporates a custom gesture recognizer to facilitate swipe-back navigation.

In this article, I will provide a solution for updating the status of the custom gesture recognizer, as discussed in part 1.

Usage

CustomNavigationStack — embed PageTwo()
  1. Introduced a toggle to control the state property: @State private var isSwipeEnabled
  2. Implemented a .disabledInteractivePopGesture(Binding<Bool>) method to bind the value of isSwipeEnabled

💡 When returning to the previous page, the status will always revert to its default value: true

Solution

Let’s break down the problem into smaller subtasks and address them sequentially.

  • Handling Gesture Modification with View Modifier: We’ve created an InterPopGestureModifier to manage the behavior of gesture modifications.
  • Attaching a Listener to CustomNavigationStack: This listener is responsible for handling the events emit by InterPopGestureModifier and update its state property isSwipeEnabled .
  • (Safety check) Verifying Gesture name: Safety checks to ensure the existence of the gesture name before attempting to post notifications. These checks prevent unexpected issues in case the gesture name is not available.

Handling Gesture Modifications

Now, let’s examine the InterPopGestureModifier:

ViewModifier — InterPopGestureModifier
  • InterPopGestureModifier is a view modifier that controls the interactive pop gesture's behavior based on the disabled binding.
  • The onChange modifier (available on iOS 17.0 and later) listens to changes in the disabled state and, when it changes and when this view initially appears, posts a notification to enable or disable the gesture accordingly. This is interchangeable with a combination of onAppear and onChange if supported iOS 16.0 version and below.
  • The onDisappear modifier ensures that the gesture is re-enabled when the view disappears.
  • Safety check: It uses an environment variable popGestureID to identify the specific gesture to control.

popGestureID

This identifier is essential for distinguishing the specific gesture. Currently, the method I’ve employed is passing the name to the environment. Please feel free to share any alternative solutions if you have them 😊.

  1. Create Environment Key
EnvironmentKey — PopNotificationID

2. Sets the environment value of the specified key path to the given value in CustomNavigationStack.

CustomNavigationStack

3. Use it on InterPopGestureModifier

Attached Listener to CustomNavigationStack

CustomNavigationStack
  • The onReceive modifier listens to a notification with the name corresponding to the interactivePopGestureRecognizer.name. When it receives this notification, it checks the status from the user info and updates the interactivePopGestureRecognizer.isEnabled accordingly.

Finally, we wrap our View Modifier into View extension

Extension — disabledInteractivePopGesture

👩🏽‍💻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)
                }
        }
        .environment(\.popGestureID, interactivePopGestureRecognizer.name)
        .onReceive(NotificationCenter.default.publisher(for: .init(interactivePopGestureRecognizer.name ?? ""))) { info in
            if let userInfo = info.userInfo, let status = userInfo["status"] as? Bool {
                interactivePopGestureRecognizer.isEnabled = status
            }
        }
    }
}

extension View {
    func disabledInteractivePopGesture(
        _ disabled: Binding<Bool> = .constant(true)
    ) -> some View {
        self.modifier(InterPopGestureModifier(disabled: disabled))
    }
}

//MARK: - View Modifier
fileprivate struct InterPopGestureModifier: ViewModifier {
    @Binding var disabled: Bool
    
    @Environment(\.popGestureID) private var gestureID
    func body(content: Content) -> some View {
        content
            .onChange(of: disabled, initial: false, { oldValue, newValue in
                guard let gestureID else { return }
                NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                    "status": !disabled
                ])
            })
            .onDisappear {
                guard let gestureID else { return }
                NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                    "status": true
                ])
            }
    }
}


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)
                }
            }
        }
    }
}

👩🏽‍💻Helper & EnvironmentKey

//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)
    }
}

fileprivate extension UIView {
    var parentViewController: UIViewController? {
        sequence(first: self) { $0.next }.first(where: { $0 is UIViewController }) as? UIViewController
    }
}

//MARK: - EnvironmentKey
fileprivate struct PopNotificationID: EnvironmentKey {
    static var defaultValue: String?
}

extension EnvironmentValues {
    var popGestureID: String? {
        get {
            self[PopNotificationID.self]
        }
        set {
            self[PopNotificationID.self] = newValue
        }
    }
}

Usage

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            CustomNavigationStack {
                NavigationLink("Go Page 2") {
                    PageTwo()
                }
            }
        }
    }
}

struct PageTwo: View {
    @Environment (\.dismiss) var dismiss
    
    @State private var isSwipeEnabled: Bool = true
    
    var body: some View {
        Toggle("Page 2", isOn: $isSwipeEnabled)
            .padding()
            .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")
                    }
                }
            }
            // View extension - that disabled the gesture at this page only
            .disabledInteractivePopGesture($isSwipeEnabled)
    }
}

Thanks all, happy reading! 😄

Photo by Ant Rozetsky on Unsplash
Swiftui
iOS
Recommended from ReadMedium