Search code examples
iosswiftswiftuiios17

Modify Swift UI view without using iOS 17 modifier


I have some code below that creates a custom navigation stack that enables full screen swipes to navigation away from the view. I want to support iOS 17 with this custom Navigation stack but some of the on change modifiers are only for iOS 17.

How can I modify .onChange(of: isEnabled, initial: true) { oldValue, newValue in which is inside of fileprivate struct FullSwipeModifier: ViewModifier { to work with iOS16 and have the same functionality? I tried using an onReceive modifier instead but I couldn't get that to work.

Additionally where would be the best spot to execute a function when a view is swiped away. I don't want the function to execute mid swipe but rather at the end of the swipe when the view has been popped from the navigation stack. I can definitely use a onDisappear but I'd rather not for my specific case.

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

Solution

  • You can try this solution, hope it solves your query.

    First, replace the FullSwipeModifier code with the following code for iOS 16 compatibility.

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

    You can use the .onDisappear modifier in your ContentView with a condition, it will be called when the view is fully swiped.

    struct ContentView: View {
        @State private var isEnabled: Bool = false
        var body: some View {
            FullSwipeNavigationStack {
                NavigationLink("Leading Swipe View") {
                    Text("hello")
                        .enableFullSwipePop(isEnabled)
                        .onDisappear {
                            // Check here, if the view is being dismissed due to a swipe
                            if !isEnabled {
                                // "View swiped away!"
                                print("View swiped away!")
                            }
                        }
                }
            }
        }
    }