skip to Main Content

This follows on from my prior question.

I have some code that creates a custom navigation stack that enables a left-right swipe to pop a view from the stack. It basically allows the left edge swipe but anywhere on the screen instead of only the left edge.

I want to execute a function when a view is popped off the navigation stack. I do not want this function to run half way through a swipe or before the user lets go. I know I can use onDisappear but this does not run fast enough for my specific case, there is always a delay between when the view actually leaves the view and when the onDisappear is called.

Is there a way to manually call the function as soon as the view is popped off?

import SwiftUI

struct ContentView: View {
    @State private var isEnabled: Bool = false
    var body: some View {
        FullSwipeNavigationStack {
             NavigationLink("Leading Swipe View") {
                 Text("hello").enableFullSwipePop(isEnabled)
             }
        }
    }
}
struct FullSwipeNavigationStack<Content: View>: View {
    @ViewBuilder var content: Content
    /// Full Swipe Custom Gesture
    @State private var customGesture: UIPanGestureRecognizer = {
        let gesture = UIPanGestureRecognizer()
        gesture.name = UUID().uuidString
        gesture.isEnabled = false
        return gesture
    }()
    var body: some View {
        NavigationStack {
            content
                .background {
                    AttachGestureView(gesture: $customGesture)
                }
        }
        .environment(.popGestureID, customGesture.name)
        .onReceive(NotificationCenter.default.publisher(for: .init(customGesture.name ?? "")), perform: { info in
            if let userInfo = info.userInfo, let status = userInfo["status"] as? Bool {
                customGesture.isEnabled = status
            }
        })
    }
}

extension View {
    @ViewBuilder
    func enableFullSwipePop(_ isEnabled: Bool) -> some View {
        self
            .modifier(FullSwipeModifier(isEnabled: isEnabled))
    }
}

/// Custom Environment Key for Passing Gesture ID to it's subviews
fileprivate struct PopNotificationID: EnvironmentKey {
    static var defaultValue: String?
}

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

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

/// Helper Files
fileprivate struct AttachGestureView: UIViewRepresentable {
    @Binding var gesture: UIPanGestureRecognizer
    func makeUIView(context: Context) -> UIView {
        return UIView()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
            /// Finding Parent Controller
            if let parentViewController = uiView.parentViewController {
                if let navigationController = parentViewController.navigationController {
                    /// Checking if already the gesture has been added to the controller
                    if let _ = navigationController.view.gestureRecognizers?.first(where: { $0.name == gesture.name }) {
                        print("Already Attached")
                    } else {
                        navigationController.addFullSwipeGesture(gesture)
                        print("Attached")
                    }
                }
            }
        }
    }
}

fileprivate extension UINavigationController {
    /// Adding Custom FullSwipe Gesture
    /// Special thanks for this SO Answer
    /// https://stackoverflow.com/questions/20714595/extend-default-interactivepopgesturerecognizer-beyond-screen-edge
    func addFullSwipeGesture(_ 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
    }
}

2

Answers


  1. From your question and the comments, you need a solution that triggers immediately when the view is popped off the navigation stack, without the delay associated with .onDisappear. Using a NavigationStack with a path could potentially provide a more precise control over the navigation stack, allowing for immediate detection of when a view is popped. background processing, however, would not allow the action to occur precisely when the view is popped.

    So a combination of monitoring the navigation path for immediate detection of changes, and a configuration of gesture recognizers or direct intervention in the navigation logic, may offer a more suitable solution.

    Try and use a NavigationStack with a NavigationPath to monitor changes in the navigation stack, and detect when a view is popped.

    import SwiftUI
    
    @main
    struct MyApp: App {
        @StateObject private var navigationPath = NavigationPath()
    
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environmentObject(navigationPath)
            }
        }
    }
    
    struct ContentView: View {
        @EnvironmentObject var navigationPath: NavigationPath
    
        var body: some View {
            NavigationStack(path: $navigationPath) {
                VStack {
                    NavigationLink("Go to Detail View", destination: DetailView())
                    .navigationDestination(for: String.self) { id in
                        // Handle navigation to different views based on id
                        DetailView()
                    }
                }
                .onChange(of: navigationPath) { _ in
                    // That is where you check if a view was popped
                    if navigationPath.count < 2 { // Assuming DetailView makes the count 2
                        print("DetailView was popped")
                        // Execute your immediate action here
                    }
                }
            }
        }
    }
    
    struct DetailView: View {
        var body: some View {
            Text("Detail View")
            // Implement the custom swipe or button to pop the view
        }
    }
    

    For the custom gesture recognizer (UISwipeGestureRecognizer), make sure it is configured to not interfere with the navigation stack’s default behavior but still allows for detecting a swipe gesture that indicates the user intends to pop the view.

    import UIKit
    
    extension DetailView {
        func setupCustomGesture(in view: UIView) {
            let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
            swipeGesture.direction = [.left, .right] // Customize as needed
            view.addGestureRecognizer(swipeGesture)
        }
        
        @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {
            // Assuming you have access to navigationPath or a method to pop the view
            // Trigger the pop here and the onChange in ContentView will handle the action
        }
    }
    

    In the DetailView, you would call setupCustomGesture passing the view in which you want to detect the gesture. That could be done in a UIViewRepresentable if integrating with SwiftUI, making sure the gesture recognizer is attached to the correct view.

    The idea remains to use:

    • Navigation Path Monitoring: detects changes in the navigation stack, including pops, and triggers immediate actions based on those changes.
    • Custom Gesture Recognizer: offers precise control over gesture-based navigation, allowing for custom actions to be executed right before the pop action is finalized.
    Login or Signup to reply.
  2. I have good news. Reading comments an idead popped (no pun intended) to my mind: listening to changes of the path could help us detect whether a view was being popped.
    Since it was me who originally posted the code here: Swiftui page switch stuck when navigationbar changed in IOS17.2 of the FullSwipeNavigationStack, It was also me who had to tackle this issue.
    I was already working on the solution when I saw the first answer, and of course I had chosen a similar path since it was the most logical road to take. Here’s the code:

    struct SwipeView: View {
        
        @State private var path: NavigationPath = NavigationPath()
        @State private var isEnabled: Bool = false
        
        
        var body: some View {
            FullSwipeNavigationStack(path: $path) {
                NavigationLink("Leading Swipe View 1", value: "1")
                    //.enableFullSwipePop(isEnabled)
            }
            /// You could place it here too as long as the path variable is accessible.
    //        .customOnChange(value: path, completion: { newValue, oldValue in
    //            if let oldValue = oldValue {
    //                /// I noticed that checking wheter the path of newValue.count is lower then oldValue.count then a view has been POPPED!
    //                let isNewValueHigher = newValue.count < oldValue.count
    //                if isNewValueHigher {
    //                    print("View has been Popped! iOS 17+")
    //                }
    //            } else {
    //                let isNewValueHigher = newValue.count < self.oldPathCount
    //                if isNewValueHigher {
    //                    print("View has been Popped! iOS 16")
    //                }
    //            }
    //            self.oldPathCount = newValue.count
    //        })
        }
    }
    
    struct FullSwipeNavigationStack<Content: View>: View {
        
        //MARK: - PROPERTIES
        @Binding var path: NavigationPath
        @ViewBuilder var content: Content
        @State private var oldPathCount: Int = 0
        @State private var customGesture: UIPanGestureRecognizer = {
            let gesture = UIPanGestureRecognizer()
            gesture.name = UUID().uuidString
            gesture.isEnabled = true
            return gesture
        }()
        
        var body: some View {
            NavigationStack(path: $path) {
                content
                    .background {
                        AttachGestureView(gesture: $customGesture)
                    }
                    .navigationDestination(for: String.self) { value in
                        VStack {
                            Text("Text sent is (value)")
                            NavigationLink("Go to Leading Swipe View (Int(value)! + 1)", value: "(Int(value)! + 1)")
                        }
                    }
                
            }
            .environment(.popGestureID, customGesture.name)
            .onReceive(NotificationCenter.default.publisher(for: .init(customGesture.name ?? "")), perform: { info in
                if let userInfo = info.userInfo, let status = userInfo["status"] as? Bool {
                    customGesture.isEnabled = status
                }
            })
            /// Use the onchange to detect changes in the path variable
            .customOnChange(value: path, completion: { newValue, oldValue in
                if let oldValue = oldValue {
                    /// I noticed that checking wheter the path of newValue.count is lower then oldValue.count then a view has been POPPED!
                    let isNewValueHigher = newValue.count < oldValue.count
                    if isNewValueHigher {
                        print("View has been Popped! iOS 17+")
                    }
                } else {
                    let isNewValueHigher = newValue.count < self.oldPathCount
                    if isNewValueHigher {
                        print("View has been Popped! iOS 16")
                    }
                }
                self.oldPathCount = newValue.count
            })
        }
    }
    

    So, I tried a simle thing like comparing the count of the oldPath with the new one, this will work assuming your minimum deployment target is iOS 17, but I got a solution for that too: I wrote a customOnChange modifier to handle both iOS 16 and iOS 17+ devices.
    Here it is:

    //MARK: - Extensions
    extension View {
        
        @ViewBuilder
        func customOnChange<Value: Equatable>(value: Value, initial: Bool = false, completion: @escaping (Value, Value?) -> Void) -> some View {
            if #available(iOS 17, *) {
                self
                    .onChange(of: value, initial: initial) { oldValue, newValue in
                        completion(newValue, oldValue)
                    }
            } else {
                self
                    .onChange(of: value) { newValue in
                        completion(newValue, nil)
                    }
                    .onAppear {
                        if initial {
                            completion(value, nil)
                        }
                    }
            }
        }
        
    }
    

    If you want to support iOS 16 you’ll have to declare an Int State variable too to keep track of the previous path count. I’ve tested both platforms and it works as expected.

    Here’s the result:

    Popping Views

    Let me know if this can work for you!

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search