Comment redonner au geste de balayage dans SwiftUI le même comportement que dans UIKit (interactivePopGestureRecognizer)

9

La reconnaissance interactive des gestes pop devrait permettre à l'utilisateur de revenir à la vue précédente dans la pile de navigation lorsqu'il glisse plus de la moitié de l'écran (ou quelque chose autour de ces lignes). Dans SwiftUI, le geste n'est pas annulé lorsque le balayage n'est pas assez loin.

SwiftUI: https://imgur.com/xxVnhY7

UIKit: https://imgur.com/f6WBUne


Question:

Est-il possible d'obtenir le comportement UIKit lors de l'utilisation des vues SwiftUI?


Tentatives

J'ai essayé d'incorporer un UIHostingController dans un UINavigationController mais cela donne exactement le même comportement 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
la source

Réponses:

4

J'ai fini par remplacer la valeur par défaut NavigationViewet NavigationLinkobtenir le comportement souhaité. Cela semble si simple que je dois ignorer quelque chose que les vues SwiftUI par défaut font?

NavigationView

J'enveloppe un UINavigationControllerdans un super simple UIViewControllerRepresentablequi donne UINavigationControllerà la vue du contenu SwiftUI comme un objet environnement. Cela signifie que vous NavigationLinkpouvez le récupérer plus tard tant qu'il se trouve dans le même contrôleur de navigation (les contrôleurs de vue présentés ne reçoivent pas les objets d'environnement), ce qui est exactement ce que nous voulons.

Remarque: La NavigationView a besoin .edgesIgnoringSafeArea(.top)et je ne sais pas encore comment définir cela dans la structure elle-même. Voir l'exemple si votre nvc coupe en haut.

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

Je crée un NavigationLink personnalisé qui accède aux environnements UINavigationController pour pousser un UIHostingController hébergeant la vue suivante.

Remarque: Je n'ai pas implémenté le selectionet isActiveque SwiftUI.NavigationLink a parce que je ne comprends pas encore complètement ce qu'ils font. Si vous souhaitez nous aider, veuillez commenter / modifier.

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

Cela résout le balayage arrière ne fonctionnant pas correctement sur SwiftUI et parce que j'utilise les noms NavigationView et NavigationLink, mon projet entier est passé immédiatement à ceux-ci.

Exemple

Dans l'exemple, je montre également la présentation modale.

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: J'ai commencé avec "Cela semble si simple que je dois oublier quelque chose" et je pense que je l'ai trouvé. Cela ne semble pas transférer EnvironmentObjects à la vue suivante. Je ne sais pas comment le NavigationLink par défaut fait cela, donc pour l'instant j'envoie manuellement des objets à la vue suivante où j'en ai besoin.

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

Modifier 2:

Cela expose le contrôleur de navigation à toutes les vues à l'intérieur NavigationViewen faisant @EnvironmentObject var nvc: UINavigationController. La façon de résoudre ce problème fait de l'environnementObjet que nous utilisons pour gérer la navigation une classe fileprivate. J'ai corrigé cela dans l'essentiel: https://gist.github.com/Amzd/67bfd4b8e41ec3f179486e13e9892eeb

Casper Zandbergen
la source
Le type d'argument «UINavigationController» n'est pas conforme au type attendu «ObservableObject»
stardust4891
@kejodion J'ai oublié d'ajouter cela au post stackoverflow mais c'était dans l'essentiel:extension UINavigationController: ObservableObject {}
Casper Zandbergen
Il a corrigé un bug de balayage arrière que je rencontrais, mais malheureusement, il ne semble pas reconnaître les modifications apportées aux demandes de récupération et ainsi de suite comme le fait NavigationView par défaut.
stardust4891
@kejodion Ah c'est dommage, je sais que cette solution a des problèmes avec environmentObjects. Je ne sais pas quelles demandes de récupération vous voulez dire. Peut-être ouvrir une nouvelle question.
Casper Zandbergen
Eh bien, j'ai plusieurs demandes de récupération qui sont automatiquement mises à jour dans l'interface utilisateur lorsque j'enregistre le contexte de l'objet géré. Pour une raison quelconque, ils ne fonctionnent pas lorsque j'implémente votre code. Je souhaite vraiment qu'ils l'aient fait, car cela a résolu un problème de balayage arrière que j'essaie de résoudre depuis des jours.
stardust4891
1

Vous pouvez le faire en descendant dans UIKit et en utilisant votre propre UINavigationController.

Créez d'abord un SwipeNavigationControllerfichier:

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

C'est la même chose SwipeNavigationControllerfournie ici , avec l'ajout de la pushSwipeBackView()fonction.

Cette fonction nécessite un SwipeBackHostingControllerque nous définissons comme

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

Nous configurons ensuite les applications SceneDelegatepour utiliser 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()
    }

Enfin, utilisez-le dans votre 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)
    }
}
Neptune
la source
1
Votre SwipeNavigationController personnalisé ne change rien du comportement par défaut de UINavigationController. Le func navController()saisir le vc puis le pousser vous-même est en fait une excellente idée et m'a aidé à résoudre ce problème! Je vais répondre à une réponse plus conviviale SwiftUI, mais merci pour votre aide!
Casper Zandbergen