SwiftUI - wie vermeide ich, dass die Navigation in der Ansicht fest codiert ist?

33

Ich versuche, die Architektur für eine größere, produktionsbereite SwiftUI-App zu erstellen. Ich stoße ständig auf das gleiche Problem, das auf einen großen Designfehler in SwiftUI hinweist.

Trotzdem konnte mir niemand eine voll funktionsfähige, produktionsbereite Antwort geben.

Wie mache ich wiederverwendbare Ansichten, in SwiftUIdenen Navigation enthalten ist?

Da das SwiftUI NavigationLinkstark an die Ansicht gebunden ist, ist dies einfach nicht so möglich, dass es auch in größeren Apps skaliert. NavigationLinkIn diesen kleinen Beispielen funktionieren Apps zwar - aber nicht, sobald Sie viele Ansichten in einer App wiederverwenden möchten. Und vielleicht auch über Modulgrenzen hinweg wiederverwenden. (wie: Wiederverwendung von View in iOS, WatchOS usw.)

Das Designproblem: NavigationLinks sind in der Ansicht fest codiert.

NavigationLink(destination: MyCustomView(item: item))

Aber wenn die Ansicht, die dies enthält NavigationLink, wiederverwendbar sein sollte, kann ich das Ziel nicht fest codieren . Es muss einen Mechanismus geben, der das Ziel bereitstellt. Ich habe das hier gefragt und eine ziemlich gute Antwort bekommen, aber immer noch nicht die vollständige Antwort:

SwiftUI MVVM-Koordinator / Router / NavigationLink

Die Idee war, die Ziel-Links in die wiederverwendbare Ansicht einzufügen. Im Allgemeinen funktioniert die Idee, aber leider lässt sich dies nicht auf echte Produktions-Apps skalieren. Sobald ich mehrere wiederverwendbare Bildschirme habe, stoße ich auf das logische Problem, dass eine wiederverwendbare Ansicht ( ViewA) ein vorkonfiguriertes Ansichtsziel ( ViewB) benötigt. Was aber, wenn ViewBauch ein vorkonfiguriertes Ansichtsziel benötigt wird ViewC? Ich würde erstellen müssen ViewBbereits in einer solchen Art und Weise , die ViewCin bereits eingespritzt wird , ViewBbevor ich spritze ViewBin ViewA. Und so weiter ... aber da die Daten, die zu diesem Zeitpunkt übergeben werden müssen, nicht verfügbar sind, schlägt das gesamte Konstrukt fehl.

Eine andere Idee, die ich hatte, war, den EnvironmentMechanismus der Abhängigkeitsinjektion zu verwenden, um Ziele für zu injizieren NavigationLink. Ich denke jedoch, dass dies mehr oder weniger als Hack und nicht als skalierbare Lösung für große Apps betrachtet werden sollte. Wir würden die Umwelt grundsätzlich für alles nutzen. Da die Umgebung jedoch auch nur innerhalb von Views verwendet werden kann (nicht in separaten Koordinatoren oder ViewModels), würde dies meiner Meinung nach wiederum seltsame Konstrukte erzeugen.

Wie Geschäftslogik (z. B. Ansichtsmodellcode) und Ansicht müssen auch Navigation und Ansicht getrennt werden (z. B. das Koordinatormuster). Dies UIKitist möglich, weil wir auf UIViewControllerund UINavigationControllerhinter der Ansicht zugreifen . UIKit'sMVC hatte bereits das Problem, dass es so viele Konzepte zusammenbrachte, dass es zum lustigen Namen "Massive-View-Controller" anstelle von "Model-View-Controller" wurde. Jetzt geht ein ähnliches Problem weiter, SwiftUIaber meiner Meinung nach noch schlimmer. Navigation und Ansichten sind stark gekoppelt und können nicht entkoppelt werden. Daher ist es nicht möglich, wiederverwendbare Ansichten zu erstellen, wenn diese eine Navigation enthalten. Es war möglich, dies zu lösen, UIKitaber jetzt sehe ich keine vernünftige Lösung inSwiftUI. Leider hat Apple uns keine Erklärung gegeben, wie wir solche Architekturprobleme lösen können. Wir haben nur einige kleine Beispiel-Apps.

Ich würde gerne das Gegenteil beweisen. Bitte zeigen Sie mir ein sauberes App-Design-Muster, das dieses Problem für große produktionsreife Apps löst.

Danke im Voraus.


Update: Diese Prämie endet in wenigen Minuten und leider konnte noch niemand ein funktionierendes Beispiel liefern. Aber ich werde eine neue Prämie starten, um dieses Problem zu lösen, wenn ich keine andere Lösung finde und sie hier verlinke. Vielen Dank an alle für ihren tollen Beitrag!

Darko
quelle
1
Einverstanden! Ich habe vor vielen Monaten in "Feedback Assistant" eine Anfrage dazu erstellt, noch keine Antwort: gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245
Sajjon
@Sajjon Danke! Ich habe vor, auch Apple zu schreiben. Mal sehen, ob ich eine Antwort bekomme.
Darko
1
A schrieb diesbezüglich einen Brief an Apple. Mal sehen, ob wir eine Antwort bekommen.
Darko
1
Nett! Es wäre mit Abstand das beste Geschenk während der WWDC!
Sajjon

Antworten:

10

Der Verschluss ist alles was Sie brauchen!

struct ItemsView<Destination: View>: View {
    let items: [Item]
    let buildDestination: (Item) -> Destination

    var body: some View {
        NavigationView {
            List(items) { item in
                NavigationLink(destination: self.buildDestination(item)) {
                    Text(item.id.uuidString)
                }
            }
        }
    }
}

Ich habe einen Beitrag über das Ersetzen des Delegatenmusters in SwiftUI durch Schließungen geschrieben. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/

Mecid
quelle
Der Verschluss ist eine gute Idee, danke! Aber wie würde das in einer Deep-View-Hierarchie aussehen? Stellen Sie sich vor, ich habe eine Navigationsansicht, die 10 Ebenen tiefer, detaillierter, detaillierter, detaillierter usw. geht
Darko
Ich möchte Sie einladen, einen einfachen Beispielcode mit nur drei Ebenen zu zeigen.
Darko
7

Meine Idee wäre so ziemlich eine Kombination aus Coordinatorund DelegateMuster. Erstellen Sie zunächst eine CoordinatorKlasse:


struct Coordinator {
    let window: UIWindow

      func start() {
        var view = ContentView()
        window.rootViewController = UIHostingController(rootView: view)
        window.makeKeyAndVisible()
    }
}

Passen Sie das SceneDelegatean, um Folgendes zu verwenden Coordinator:

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let coordinator = Coordinator(window: window)
            coordinator.start()
        }
    }

Im Inneren ContentViewhaben wir Folgendes:


struct ContentView: View {
    var delegate: ContentViewDelegate?

    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: delegate!.didSelect(Item())) {
                    Text("Destination1")
                }
            }
        }
    }
}

Wir können das ContenViewDelegateProtokoll folgendermaßen definieren :

protocol ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView
}

Wo Itemnur eine Struktur ist, die identifizierbar ist, könnte alles andere sein (z. B. ID eines Elements wie in aTableView in UIKit)

Der nächste Schritt besteht darin, dieses Protokoll zu übernehmen Coordinatorund einfach die Ansicht zu übergeben, die Sie präsentieren möchten:

extension Coordinator: ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView {
        AnyView(Text("Returned Destination1"))
    }
}

Dies hat bisher in meinen Apps gut funktioniert. Ich hoffe, es hilft.

Nikola Matijevic
quelle
Danke für den Beispielcode. Ich möchte Sie einladen, Text("Returned Destination1")zu so etwas zu wechseln MyCustomView(item: ItemType, destinationView: View). Dazu müssen also MyCustomViewauch einige Daten und Ziele eingespeist werden. Wie würden Sie das lösen?
Darko
Sie stoßen auf das Verschachtelungsproblem, das ich in meinem Beitrag beschreibe. Bitte korrigieren Sie mich, wenn ich falsch liege. Grundsätzlich ist dieser Ansatz funktioniert , wenn Sie eine wiederverwendbare Ansicht haben und dass wieder verwendbare Ansicht ist nicht eine andere wieder verwendbare Ansicht mit NavigationLink enthalten. Dies ist ein recht einfacher Anwendungsfall, der sich jedoch nicht auf große Apps skalieren lässt. (wo fast jede Ansicht wiederverwendbar ist)
Darko
Dies hängt stark davon ab, wie Sie Ihre App-Abhängigkeiten und deren Ablauf verwalten. Wenn Sie Abhängigkeiten an einem einzigen Ort haben, wie Sie IMO (auch als Composition Root bezeichnet) sollten, sollten Sie nicht auf dieses Problem stoßen.
Nikola Matijevic
Für mich funktioniert es, alle Ihre Abhängigkeiten für eine Ansicht als Protokoll zu definieren. Fügen Sie dem Protokoll in der Kompositionswurzel Konformität hinzu. Übergeben Sie Abhängigkeiten an den Koordinator. Injizieren Sie sie vom Koordinator. Theoretisch sollten Sie am Ende mehr als drei Parameter haben, wenn dies richtig gemacht wird, niemals mehr als dependenciesund destination.
Nikola Matijevic
1
Ich würde gerne ein konkretes Beispiel sehen. Wie ich bereits erwähnt habe, fangen wir an Text("Returned Destination1"). Was passiert , wenn diese Bedürfnisse ein sein MyCustomView(item: ItemType, destinationView: View). Was wirst du dort spritzen? Ich verstehe Abhängigkeitsinjektion, lose Kopplung durch Protokolle und gemeinsame Abhängigkeiten mit Koordinatoren. All das ist nicht das Problem - es ist die notwendige Verschachtelung. Vielen Dank.
Darko
2

Mir fällt ein, dass wenn Sie sagen:

Was aber, wenn ViewB auch ein vorkonfiguriertes Ansichtsziel ViewC benötigt? Ich müsste ViewB bereits so erstellen, dass ViewC bereits in ViewB injiziert wird, bevor ich ViewB in ViewA injiziere. Und so weiter ... aber da die Daten, die zu diesem Zeitpunkt übergeben werden müssen, nicht verfügbar sind, schlägt das gesamte Konstrukt fehl.

es ist nicht ganz wahr. Anstatt Ansichten bereitzustellen, können Sie Ihre wiederverwendbaren Komponenten so gestalten, dass Sie Verschlüsse bereitstellen, die bei Bedarf Ansichten bereitstellen.

Auf diese Weise kann der Abschluss, der ViewB bei Bedarf erzeugt, einen Abschluss liefern, der ViewC bei Bedarf erzeugt. Die eigentliche Erstellung der Ansichten kann jedoch zu einem Zeitpunkt erfolgen, an dem die von Ihnen benötigten Kontextinformationen verfügbar sind.

Sam Deane
quelle
Aber wie unterscheidet sich die Erstellung eines solchen „Closure-Tree“ von den tatsächlichen Ansichten? Das Problem mit dem Element, das bereitgestellt wird, wäre gelöst, aber nicht das erforderliche Verschachteln. Ich erstelle einen Verschluss, der eine Ansicht schafft - ok. Aber in dieser Schließung müsste ich bereits die Schaffung der nächsten Schließung vorsehen. Und im letzten den nächsten. Usw. aber vielleicht verstehe ich dich falsch. Ein Codebeispiel würde helfen. Vielen Dank.
Darko
2

Hier ist ein unterhaltsames Beispiel für einen unendlichen Drilldown und das programmgesteuerte Ändern Ihrer Daten für die nächste Detailansicht

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    var body: some View {
        NavigationView {
            DynamicView(viewModel: ViewModel(message: "Get Information", type: .information))
        }
    }
}

struct DynamicView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    let viewModel: ViewModel

    var body: some View {
        VStack {
            if viewModel.type == .information {
                InformationView(viewModel: viewModel)
            }
            if viewModel.type == .person {
                PersonView(viewModel: viewModel)
            }
            if viewModel.type == .productDisplay {
                ProductView(viewModel: viewModel)
            }
            if viewModel.type == .chart {
                ChartView(viewModel: viewModel)
            }
            // If you want the DynamicView to be able to be other views, add to the type enum and then add a new if statement!
            // Your Dynamic view can become "any view" based on the viewModel
            // If you want to be able to navigate to a new chart UI component, make the chart view
        }
    }
}

struct InformationView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.blue)


            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct PersonView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.red)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ProductView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ChartView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ViewModel {
    let message: String
    let type: DetailScreenType
}

enum DetailScreenType: String {
    case information
    case productDisplay
    case person
    case chart
}

class NavigationManager: ObservableObject {
    func destination(forModel viewModel: ViewModel) -> DynamicView {
        DynamicView(viewModel: generateViewModel(context: viewModel))
    }

    // This is where you generate your next viewModel dynamically.
    // replace the switch statement logic inside with whatever logic you need.
    // DYNAMICALLY MAKE THE VIEWMODEL AND YOU DYNAMICALLY MAKE THE VIEW
    // You could even lead to a view with no navigation link in it, so that would be a dead end, if you wanted it.
    // In my case my "context" is the previous viewMode, by you could make it something else.
    func generateViewModel(context: ViewModel) -> ViewModel {
        switch context.type {
        case .information:
            return ViewModel(message: "Serial Number 123", type: .productDisplay)
        case .productDisplay:
            return ViewModel(message: "Susan", type: .person)
        case .person:
            return ViewModel(message: "Get Information", type: .chart)
        case .chart:
            return ViewModel(message: "Chart goes here. If you don't want the navigation link on this page, you can remove it! Or do whatever you want! It's all dynamic. The point is, the DynamicView can be as dynamic as your model makes it.", type: .information)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
        .environmentObject(NavigationManager())
    }
}
MScottWaller
quelle
-> Einige Ansichten zwingen Sie dazu, immer nur einen Ansichtstyp zurückzugeben.
Darko
Die Abhängigkeitsinjektion mit EnvironmentObject löst einen Teil des Problems. Aber: Sollte etwas Entscheidendes und Wichtiges in einem UI-Framework so komplex sein ...?
Darko
Ich meine - wenn die Abhängigkeitsinjektion die einzige Lösung dafür ist, würde ich sie widerwillig akzeptieren. Aber das würde wirklich riechen ...
Darko
1
Ich verstehe nicht, warum Sie dies mit Ihrem Framework-Beispiel nicht verwenden konnten. Wenn Sie über ein Framework sprechen, das eine unbekannte Ansicht vermittelt, würde ich mir vorstellen, dass es nur eine Ansicht zurückgeben könnte. Es würde mich auch nicht wundern, wenn eine AnyView in einem NavigationLink nicht wirklich ein so großer Pref-Hit wäre, da die übergeordnete Ansicht vollständig vom tatsächlichen Layout des Kindes getrennt ist. Ich bin allerdings kein Experte, es müsste getestet werden. Anstatt alle nach Beispielcode zu fragen, bei dem sie Ihre Anforderungen nicht vollständig verstehen können, schreiben Sie ein UIKit-Beispiel und fordern Sie Übersetzungen an.
Jasongregori
1
Mit diesem Design funktioniert im Grunde die (UIKit) App, an der ich arbeite. Es werden Modelle generiert, die mit anderen Modellen verknüpft sind. Ein zentrales System legt fest, welche VC für dieses Modell geladen werden soll, und die übergeordnete VC schiebt sie dann auf den Stapel.
Jasongregori
2

Ich schreibe eine Blogpost-Reihe zum Erstellen eines MVP + Coordinators-Ansatzes in SwiftUI, der nützlich sein kann:

https://lascorbe.com/posts/2020-04-27-MVPCoordinators-SwiftUI-part1/

Das vollständige Projekt ist auf Github verfügbar: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

Ich versuche es so zu machen, als wäre es eine große App in Bezug auf Skalierbarkeit. Ich glaube, ich habe das Navigationsproblem gelöst, aber ich muss noch sehen, wie man Deep Linking macht, woran ich gerade arbeite. Ich hoffe, es hilft.

Luis Ascorbe
quelle
Wow, das ist großartig, danke! Sie haben bei der Implementierung von Koordinatoren in SwiftUI sehr gute Arbeit geleistet. Die Idee, NavigationViewdie Root-Ansicht zu erstellen, ist fantastisch. Dies ist bei weitem die fortschrittlichste Implementierung von SwiftUI Coordinators, die ich bei weitem gesehen habe.
Darko vor
Ich möchte Ihnen das Kopfgeld gewähren, nur weil Ihre Coordinator-Lösung wirklich großartig ist. Das einzige Problem, das ich habe - es geht nicht wirklich auf das Problem ein, das ich beschreibe. Es entkoppelt sich, NavigationLinkaber es tut dies, indem es eine neue gekoppelte Abhängigkeit einführt. Das MasterViewin deinem Beispiel ist nicht abhängig von NavigationButton. Stellen Sie sich vor, Sie platzieren MasterViewein Swift-Paket - es wird nicht mehr kompiliert, da der Typ NavigationButtonunbekannt ist. Auch sehe ich nicht, wie das Problem der verschachtelten Wiederverwendbarkeit Viewsdadurch gelöst würde?
Darko vor
Ich würde mich freuen, falsch zu liegen, und wenn ich es bin, erkläre es mir bitte. Obwohl das Kopfgeld in wenigen Minuten aufgebraucht ist, hoffe ich, dass ich Ihnen die Punkte irgendwie verleihen kann. (Noch nie ein Kopfgeld gemacht, aber ich denke, ich kann einfach eine Folgefrage mit einer neuen erstellen?)
Darko vor
1

Dies ist eine völlig unkonventionelle Antwort, die sich wahrscheinlich als Unsinn herausstellen wird, aber ich wäre versucht, einen hybriden Ansatz zu verwenden.

Verwenden Sie die Umgebung, um ein einzelnes Koordinatorobjekt zu durchlaufen - nennen wir es NavigationCoordinator.

Geben Sie Ihren wiederverwendbaren Ansichten eine Art Kennung, die dynamisch festgelegt wird. Diese Kennung gibt semantische Informationen an, die dem tatsächlichen Anwendungsfall und der Navigationshierarchie der Clientanwendung entsprechen.

Lassen Sie die wiederverwendbaren Ansichten den Navigationskoordinator nach der Zielansicht abfragen und ihre Kennung und die Kennung des Ansichtstyps übergeben, zu dem sie navigieren.

Dadurch bleibt der NavigationCoordinator als einzelner Injektionspunkt und es handelt sich um ein Nichtansichtsobjekt, auf das außerhalb der Ansichtshierarchie zugegriffen werden kann.

Während des Setups können Sie die richtigen Ansichtsklassen für die Rückgabe registrieren, indem Sie eine Art Übereinstimmung mit den zur Laufzeit übergebenen Bezeichnern verwenden. In einigen Fällen kann etwas so Einfaches wie das Abgleichen mit der Zielkennung funktionieren. Oder Abgleich mit einem Paar von Host- und Zielkennungen.

In komplexeren Fällen können Sie einen benutzerdefinierten Controller schreiben, der andere app-spezifische Informationen berücksichtigt.

Da es über die Umgebung eingefügt wird, kann jede Ansicht den Standard-Navigationskoordinator an jedem Punkt überschreiben und seinen Unteransichten einen anderen zuweisen.

Sam Deane
quelle