Como devolver o gesto de furto no SwiftUI o mesmo comportamento que no UIKit (InteractivePopGestureRecognizer)

9

O reconhecedor interativo de gestos pop deve permitir que o usuário volte à exibição anterior na pilha de navegação quando deslizar mais da metade da tela (ou algo em torno dessas linhas). No SwiftUI, o gesto não é cancelado quando o furto não foi longe o suficiente.

SwiftUI: https://imgur.com/xxVnhY7

UIKit: https://imgur.com/f6WBUne


Questão:

É possível obter o comportamento do UIKit enquanto estiver usando as visualizações SwiftUI?


Tentativas

Tentei incorporar um UIHostingController dentro de um UINavigationController, mas isso oferece exatamente o mesmo comportamento que o 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
fonte

Respostas:

4

Acabei substituindo o padrão NavigationViewe NavigationLinkobtendo o comportamento desejado. Isso parece tão simples que devo estar ignorando algo que as visualizações SwiftUI padrão fazem?

NavigationView

Eu envolvo um UINavigationControllerem um super simples UIViewControllerRepresentableque fornece a UINavigationControllervisualização de conteúdo SwiftUI como um environmentObject. Isso significa NavigationLinkque, posteriormente, é possível obter, desde que esteja no mesmo controlador de navegação (os controladores de exibição apresentados não recebem o environmentObjects), que é exatamente o que queremos.

Nota: O NavigationView precisa .edgesIgnoringSafeArea(.top)e ainda não sei como definir isso na estrutura. Veja o exemplo se o seu nvc é cortado na 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

Criei um NavigationLink personalizado que acessa os ambientes UINavigationController para enviar por push um UIHostingController que hospeda a próxima exibição.

Nota: Eu não implementei o selectione isActiveque o SwiftUI.NavigationLink possui porque ainda não compreendo completamente o que eles fazem. Se você quiser ajudar com isso, comente / edite.

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

Isso resolve o deslize traseiro que não está funcionando corretamente no SwiftUI e, como uso os nomes NavigationView e NavigationLink, todo o meu projeto mudou para esses imediatamente.

Exemplo

No exemplo, também mostro apresentação 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")
        }
    }
}

Edit: Comecei com "Isso parece tão simples que devo estar ignorando algo" e acho que o encontrei. Isso não parece transferir o EnvironmentObjects para a próxima exibição. Não sei como o NavigationLink padrão faz isso. Por enquanto, envio objetos manualmente para a próxima exibição onde preciso deles.

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

Edição 2:

Isso expõe o controlador de navegação a todas as visualizações internas NavigationView, fazendo @EnvironmentObject var nvc: UINavigationController. A maneira de corrigir isso é tornar o environmentObject que usamos para gerenciar a navegação em uma classe privada de arquivo. Corrigi isso na essência: https://gist.github.com/Amzd/67bfd4b8e41ec3f179486e13e9892eeb

Casper Zandbergen
fonte
O tipo de argumento 'UINavigationController' não está em conformidade com o tipo esperado 'ObservableObject'
stardust4891
@kejodion eu esqueci de acrescentar que para o cargo stackoverflow mas foi na essência:extension UINavigationController: ObservableObject {}
Casper Zandbergen
Ele corrigiu um erro de retrocesso que eu estava enfrentando, mas infelizmente não parece reconhecer as alterações para buscar solicitações e assim por diante, como o NavigationView padrão.
stardust4891
@kejodion Ah, que pena, eu sei que esta solução tem problemas com o environmentObjects. Não sabe ao certo o que deseja obter. Talvez abra uma nova pergunta.
Casper Zandbergen
Bem, tenho várias solicitações de busca que são atualizadas automaticamente na interface do usuário ao salvar o contexto do objeto gerenciado. Por alguma razão, eles não funcionam quando eu implemento seu código. REALMENTE gostaria que sim, porque isso corrigiu um problema de furto nas costas que venho tentando corrigir há dias.
precisa
1

Você pode fazer isso descendo para o UIKit e usando seu próprio UINavigationController.

Primeiro, crie um SwipeNavigationControllerarquivo:

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

É o mesmo SwipeNavigationControllerfornecido aqui , com a adição da pushSwipeBackView()função.

Esta função requer uma 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
    }
}

Em seguida, configuramos os aplicativos SceneDelegatepara usar o 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()
    }

Por fim, use-o no seu 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)
    }
}
Netuno
fonte
11
Seu SwipeNavigationController personalizado, na verdade, não altera nada do comportamento padrão de UINavigationController. O func navController()agarrar vc e depois empurrar você mesmo é realmente uma ótima idéia e me ajudou a descobrir esse problema! Vou responder a uma resposta mais amigável do SwiftUI, mas obrigado por sua ajuda!
Casper Zandbergen