Cómo devolver el gesto de deslizar en SwiftUI el mismo comportamiento que en UIKit (interactivePopGestureRecognizer)

9

El reconocedor de gestos pop interactivo debería permitir al usuario volver a la vista anterior en la pila de navegación cuando desliza más de la mitad de la pantalla (o algo alrededor de esas líneas). En SwiftUI, el gesto no se cancela cuando el deslizamiento no fue lo suficientemente lejos.

SwiftUI: https://imgur.com/xxVnhY7

UIKit: https://imgur.com/f6WBUne


Pregunta:

¿Es posible obtener el comportamiento UIKit mientras se usan las vistas SwiftUI?


Intentos

Traté de incrustar un UIHostingController dentro de un UINavigationController pero eso da exactamente el mismo comportamiento que NavigationView.

struct ContentView: View {
    var body: some View {
        UIKitNavigationView {
            VStack {
                NavigationLink(destination: Text("Detail")) {
                    Text("SwiftUI")
                }
            }.navigationBarTitle("SwiftUI", displayMode: .inline)
        }.edgesIgnoringSafeArea(.top)
    }
}

struct UIKitNavigationView<Content: View>: UIViewControllerRepresentable {

    var content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> UINavigationController {
        let host = UIHostingController(rootView: content())
        let nvc = UINavigationController(rootViewController: host)
        return nvc
    }

    func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
}
Casper Zandbergen
fuente

Respuestas:

4

Terminé anulando el valor predeterminado NavigationViewy NavigationLinkpara obtener el comportamiento deseado. Esto parece tan simple que debo pasar por alto algo que hacen las vistas predeterminadas de SwiftUI.

NavigationView

Envuelvo un UINavigationControlleren un super simple UIViewControllerRepresentableque le da UINavigationControllera la vista de contenido de SwiftUI como un objeto de entorno. Esto significa NavigationLinkque más tarde puede captar eso, siempre y cuando esté en el mismo controlador de navegación (los controladores de vista presentados no reciben el entorno Objetos), que es exactamente lo que queremos.

Nota: El NavigationView necesita .edgesIgnoringSafeArea(.top)y aún no sé cómo configurarlo en la estructura misma. Vea el ejemplo si su nvc se corta en la parte superior.

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

Creo un NavigationLink personalizado que accede a los entornos UINavigationController para impulsar un UIHostingController que aloja la siguiente vista.

Nota: No implementé el selectiony isActiveel SwiftUI.NavigationLink lo tiene porque todavía no entiendo completamente lo que hacen. Si quieres ayudar con eso, comenta / edita.

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

Esto resuelve que el deslizamiento hacia atrás no funcione correctamente en SwiftUI y debido a que uso los nombres NavigationView y NavigationLink, todo mi proyecto cambió a estos de inmediato.

Ejemplo

En el ejemplo también muestro presentación modal.

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

Editar: Comencé con "Esto parece tan simple que debo pasar por alto algo" y creo que lo encontré. Esto no parece transferir EnvironmentObjects a la siguiente vista. No sé cómo lo hace el NavigationLink predeterminado, así que por ahora envío manualmente objetos a la siguiente vista donde los necesito.

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

Edición 2:

Esto expone el controlador de navegación a todas las vistas dentro NavigationViewhaciendo @EnvironmentObject var nvc: UINavigationController. La forma de solucionar esto es hacer del entorno object que utilizamos para administrar la navegación una clase privada de archivos. Arreglé esto en la esencia: https://gist.github.com/Amzd/67bfd4b8e41ec3f179486e13e9892eeb

Casper Zandbergen
fuente
El tipo de argumento 'UINavigationController' no se ajusta al tipo esperado 'ObservableObject'
stardust4891
@kejodion Olvidé agregar eso a la publicación stackoverflow pero estaba en la esencia:extension UINavigationController: ObservableObject {}
Casper Zandbergen
Se corrigió un error de deslizamiento hacia atrás que estaba experimentando, pero desafortunadamente no parece reconocer los cambios para recuperar solicitudes y no de la manera en que lo hace el NavigationView predeterminado.
stardust4891
@kejodion Ah, eso es una lástima, sé que esta solución tiene problemas con environmentObjects. No estoy seguro de a qué solicitudes de búsqueda te refieres. Quizás abra una nueva pregunta.
Casper Zandbergen el
Bueno, tengo varias solicitudes de recuperación que se actualizan automáticamente en la interfaz de usuario cuando guardo el contexto del objeto administrado. Por alguna razón, no funcionan cuando implemento su código. REALMENTE desearía que lo hicieran, porque esto solucionó un problema de deslizamiento hacia atrás que he estado tratando de solucionar durante días.
stardust4891
1

Puede hacerlo descendiendo a UIKit y utilizando su propio UINavigationController.

Primero crea un SwipeNavigationControllerarchivo:

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

Esto es lo mismo SwipeNavigationControllerprovisto aquí , con la adición de la pushSwipeBackView()función.

Esta función requiere una SwipeBackHostingControllerque definimos como

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

Luego configuramos las aplicaciones SceneDelegatepara usar 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()
    }

Finalmente úsalo en tu 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)
    }
}
Neptuno
fuente
1
Su SwipeNavigationController personalizado en realidad no cambia nada del comportamiento predeterminado de UINavigationController. ¡ func navController()Tomar el vc y luego empujar el vc usted mismo es realmente una gran idea y me ayudó a resolver este problema! Contestaré una respuesta más amigable de SwiftUI, ¡pero gracias por su ayuda!
Casper Zandbergen