So geben Sie Swipe-Gesten in SwiftUI das gleiche Verhalten wie in UIKit (InteractivePopGestureRecognizer) zurück

9

Die interaktive Pop-Gestenerkennung sollte es dem Benutzer ermöglichen, zur vorherigen Ansicht im Navigationsstapel zurückzukehren, wenn er weiter als die Hälfte des Bildschirms (oder etwas um diese Linien herum) wischt. In SwiftUI wird die Geste nicht abgebrochen, wenn der Wisch nicht weit genug war.

SwiftUI: https://imgur.com/xxVnhY7

UIKit: https://imgur.com/f6WBUne


Frage:

Ist es möglich, das UIKit-Verhalten bei Verwendung von SwiftUI-Ansichten abzurufen?


Versuche

Ich habe versucht, einen UIHostingController in einen UINavigationController einzubetten, aber das ergibt genau das gleiche Verhalten wie 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
quelle

Antworten:

4

Am Ende habe ich die Standardeinstellung überschrieben NavigationViewund NavigationLinkdas gewünschte Verhalten erhalten. Dies scheint so einfach zu sein, dass ich etwas übersehen muss, was die Standard-SwiftUI-Ansichten tun?

NavigationView

Ich wickle ein UINavigationControllerin ein super einfaches UIViewControllerRepresentable, das UINavigationControllerdie SwiftUI-Inhaltsansicht als Umgebungsobjekt gibt. Dies bedeutet, dass der NavigationLinkspäter darauf zugreifen kann, solange er sich im selben Navigationscontroller befindet (die dargestellten Ansichtscontroller erhalten die Umgebungsobjekte nicht), was genau das ist, was wir wollen.

Hinweis: Die NavigationView benötigt .edgesIgnoringSafeArea(.top)und ich weiß noch nicht, wie ich das in der Struktur selbst einstellen soll. Siehe Beispiel, wenn Ihr NVC oben abschneidet.

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

Ich erstelle einen benutzerdefinierten NavigationLink, der auf die Umgebungen UINavigationController zugreift, um einen UIHostingController zu pushen, der die nächste Ansicht hostet.

Hinweis: Ich habe das selectionund isActivedas SwiftUI.NavigationLink nicht implementiert , da ich noch nicht vollständig verstehe, was sie tun. Wenn Sie dabei helfen möchten, kommentieren / bearbeiten Sie diese bitte.

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

Dies behebt, dass das Zurückwischen unter SwiftUI nicht richtig funktioniert. Da ich die Namen NavigationView und NavigationLink verwende, wurde mein gesamtes Projekt sofort auf diese umgestellt.

Beispiel

Im Beispiel zeige ich auch die modale Darstellung.

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

Bearbeiten: Ich begann mit "Das scheint so einfach, dass ich etwas übersehen muss" und ich denke, ich habe es gefunden. Dies scheint EnvironmentObjects nicht in die nächste Ansicht zu übertragen. Ich weiß nicht, wie der Standard-Navigationslink dies tut. Daher sende ich Objekte vorerst manuell an die nächste Ansicht, in der ich sie benötige.

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

Bearbeiten 2:

Dadurch wird der Navigationscontroller allen Ansichten im Inneren NavigationViewausgesetzt @EnvironmentObject var nvc: UINavigationController. Um dies zu beheben, wird das EnvironmentObject, mit dem wir die Navigation verwalten, zu einer dateiprivaten Klasse. Ich habe dies im Kern behoben: https://gist.github.com/Amzd/67bfd4b8e41ec3f179486e13e9892eeb

Casper Zandbergen
quelle
Der Argumenttyp 'UINavigationController' entspricht nicht dem erwarteten Typ 'ObservableObject'
stardust4891
@kejodion Ich habe vergessen, das zum Stackoverflow-Post hinzuzufügen, aber es war im Kern:extension UINavigationController: ObservableObject {}
Casper Zandbergen
Es hat einen Back-Swipe-Fehler behoben, den ich hatte, aber leider scheint es keine Änderungen beim Abrufen von Anforderungen zu bestätigen und nicht so, wie es die Standard-Navigationsansicht tut.
Sternenstaub4891
@kejodion Ah, das ist schade, ich weiß, dass diese Lösung Probleme mit Umgebungsobjekten hat. Ich bin mir nicht sicher, welche Abrufanforderungen Sie meinen. Vielleicht eine neue Frage öffnen.
Casper Zandbergen
Nun, ich habe mehrere Abrufanforderungen, die automatisch in der Benutzeroberfläche aktualisiert werden, wenn ich den Kontext des verwalteten Objekts speichere. Aus irgendeinem Grund funktionieren sie nicht, wenn ich Ihren Code implementiere. Ich wünschte wirklich, sie hätten es getan, weil dies ein Back Swipe-Problem behoben hat, das ich seit Tagen zu beheben versucht habe.
Sternenstaub4891
1

Sie können dies tun, indem Sie in UIKit absteigen und Ihren eigenen UINavigationController verwenden.

Erstellen Sie zuerst eine SwipeNavigationControllerDatei:

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

Dies ist das gleiche, SwipeNavigationControllerdas hier mit der zusätzlichen pushSwipeBackView()Funktion bereitgestellt wird .

Diese Funktion erfordert eine, SwipeBackHostingControllerdie wir als definieren

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

Wir haben dann die App so eingerichtet, dass sie Folgendes SceneDelegateverwendet 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()
    }

Verwenden Sie es schließlich in Ihrem 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)
    }
}
Neptun
quelle
1
Ihr benutzerdefinierter SwipeNavigationController ändert nichts am Standardverhalten von UINavigationController. Das func navController()Aufnehmen des VC und das anschließende Schieben des VC ist eigentlich eine großartige Idee und hat mir geholfen, dieses Problem herauszufinden! Ich werde eine SwiftUI-freundlichere Antwort beantworten, aber danke für Ihre Hilfe!
Casper Zandbergen