How to give back swipe gesture in SwiftUI the same behaviour as in UIKit (interactivePopGestureRecognizer)

后端 未结 2 1742
独厮守ぢ
独厮守ぢ 2020-12-29 10:46

The interactive pop gesture recognizer should allow the user to go back the the previous view in navigation stack when they swipe further than half the screen (or something

相关标签:
2条回答
  • 2020-12-29 10:54

    You can do this by descending into UIKit and using your own UINavigationController.

    First create a SwipeNavigationController file:

    import UIKit
    import SwiftUI
    
    final class SwipeNavigationController: UINavigationController {
    
        // MARK: - Lifecycle
    
        override init(rootViewController: UIViewController) {
            super.init(rootViewController: rootViewController)
        }
    
        override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
            super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    
            delegate = self
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
    
            delegate = self
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // This needs to be in here, not in init
            interactivePopGestureRecognizer?.delegate = self
        }
    
        deinit {
            delegate = nil
            interactivePopGestureRecognizer?.delegate = nil
        }
    
        // MARK: - Overrides
    
        override func pushViewController(_ viewController: UIViewController, animated: Bool) {
            duringPushAnimation = true
    
            super.pushViewController(viewController, animated: animated)
        }
    
        var duringPushAnimation = false
    
        // MARK: - Custom Functions
    
        func pushSwipeBackView<Content>(_ content: Content) where Content: View {
            let hostingController = SwipeBackHostingController(rootView: content)
            self.delegate = hostingController
            self.pushViewController(hostingController, animated: true)
        }
    
    }
    
    // MARK: - UINavigationControllerDelegate
    
    extension SwipeNavigationController: UINavigationControllerDelegate {
    
        func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
            guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
    
            swipeNavigationController.duringPushAnimation = false
        }
    
    }
    
    // MARK: - UIGestureRecognizerDelegate
    
    extension SwipeNavigationController: UIGestureRecognizerDelegate {
    
        func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
            guard gestureRecognizer == interactivePopGestureRecognizer else {
                return true // default value
            }
    
            // Disable pop gesture in two situations:
            // 1) when the pop animation is in progress
            // 2) when user swipes quickly a couple of times and animations don't have time to be performed
            let result = viewControllers.count > 1 && duringPushAnimation == false
            return result
        }
    }
    

    This is the same SwipeNavigationController provided here, with the addition of the pushSwipeBackView() function.

    This function requires a SwipeBackHostingController which we define as

    import SwiftUI
    
    class SwipeBackHostingController<Content: View>: UIHostingController<Content>, UINavigationControllerDelegate {
        func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
            guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
            swipeNavigationController.duringPushAnimation = false
        }
    
        override func viewWillDisappear(_ animated: Bool) {
            super.viewWillDisappear(animated)
    
            guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
            swipeNavigationController.delegate = nil
        }
    }
    

    We then set up the app's SceneDelegate to use the SwipeNavigationController:

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let hostingController = UIHostingController(rootView: ContentView())
            window.rootViewController = SwipeNavigationController(rootViewController: hostingController)
            self.window = window
            window.makeKeyAndVisible()
        }
    

    Finally use it in your ContentView:

    struct ContentView: View {
        func navController() -> SwipeNavigationController {
            return UIApplication.shared.windows[0].rootViewController! as! SwipeNavigationController
        }
    
        var body: some View {
            VStack {
                Text("SwiftUI")
                    .onTapGesture {
                        self.navController().pushSwipeBackView(Text("Detail"))
                }
            }.onAppear {
                self.navController().navigationBar.topItem?.title = "Swift UI"
            }.edgesIgnoringSafeArea(.top)
        }
    }
    
    0 讨论(0)
  • 2020-12-29 11:12

    I ended up overriding the default NavigationView and NavigationLink to get the desired behaviour. This seems so simple that I must be overlooking something that the default SwiftUI views do?

    NavigationView

    I wrap a UINavigationController in a super simple UIViewControllerRepresentable that gives the UINavigationController to the SwiftUI content view as an environmentObject. This means the NavigationLink can later grab that as long as it's in the same navigation controller (presented view controllers don't receive the environmentObjects) which is exactly what we want.

    Note: The NavigationView needs .edgesIgnoringSafeArea(.top) and I don't know how to set that in the struct itself yet. See example if your nvc cuts off at the top.

    struct NavigationView<Content: View>: UIViewControllerRepresentable {
    
        var content: () -> Content
    
        init(@ViewBuilder content: @escaping () -> Content) {
            self.content = content
        }
    
        func makeUIViewController(context: Context) -> UINavigationController {
            let nvc = UINavigationController()
            let host = UIHostingController(rootView: content().environmentObject(nvc))
            nvc.viewControllers = [host]
            return nvc
        }
    
        func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
    }
    
    extension UINavigationController: ObservableObject {}
    

    NavigationLink

    I create a custom NavigationLink that accesses the environments UINavigationController to push a UIHostingController hosting the next view.

    Note: I didn't implement the selection and isActive that the SwiftUI.NavigationLink has because I don't fully understand what they do yet. If you want to help with that please comment/edit.

    struct NavigationLink<Destination: View, Label:View>: View {
        var destination: Destination
        var label: () -> Label
    
        public init(destination: Destination, @ViewBuilder label: @escaping () -> Label) {
            self.destination = destination
            self.label = label
        }
    
        /// If this crashes, make sure you wrapped the NavigationLink in a NavigationView
        @EnvironmentObject var nvc: UINavigationController
    
        var body: some View {
            Button(action: {
                let rootView = self.destination.environmentObject(self.nvc)
                let hosted = UIHostingController(rootView: rootView)
                self.nvc.pushViewController(hosted, animated: true)
            }, label: label)
        }
    }
    

    This solves the back swipe not working correctly on SwiftUI and because I use the names NavigationView and NavigationLink my entire project switched to these immediately.

    Example

    In the example I show modal presentation too.

    struct ContentView: View {
        @State var isPresented = false
    
        var body: some View {
            NavigationView {
                VStack(alignment: .center, spacing: 30) {
                    NavigationLink(destination: Text("Detail"), label: {
                        Text("Show detail")
                    })
                    Button(action: {
                        self.isPresented.toggle()
                    }, label: {
                        Text("Show modal")
                    })
                }
                .navigationBarTitle("SwiftUI")
            }
            .edgesIgnoringSafeArea(.top)
            .sheet(isPresented: $isPresented) {
                Modal()
            }
        }
    }
    
    struct Modal: View {
        @Environment(\.presentationMode) var presentationMode
    
        var body: some View {
            NavigationView {
                VStack(alignment: .center, spacing: 30) {
                    NavigationLink(destination: Text("Detail"), label: {
                        Text("Show detail")
                    })
                    Button(action: {
                        self.presentationMode.wrappedValue.dismiss()
                    }, label: {
                        Text("Dismiss modal")
                    })
                }
                .navigationBarTitle("Modal")
            }
        }
    }
    

    Edit: I started off with "This seems so simple that I must be overlooking something" and I think I found it. This doesn't seem to transfer EnvironmentObjects to the next view. I don't know how the default NavigationLink does that so for now I manually send objects on to the next view where I need them.

    NavigationLink(destination: Text("Detail").environmentObject(objectToSendOnToTheNextView)) {
        Text("Show detail")
    }
    

    Edit 2:

    This exposes the navigation controller to all views inside NavigationView by doing @EnvironmentObject var nvc: UINavigationController. The way to fix this is making the environmentObject we use to manage navigation a fileprivate class. I fixed this in the gist: https://gist.github.com/Amzd/67bfd4b8e41ec3f179486e13e9892eeb

    0 讨论(0)
提交回复
热议问题