SwiftUI - comment éviter la navigation codée en dur dans la vue?

33

J'essaie de faire l'architecture d'une application SwiftUI plus grande et prête pour la production. Je rencontre tout le temps le même problème qui pointe vers un défaut majeur de conception dans SwiftUI.

Personne ne pouvait toujours me donner une réponse complète, prête pour la production.

Comment faire des vues réutilisables dans SwiftUIlesquelles contiennent la navigation?

Comme le SwiftUI NavigationLinkest fortement lié à la vue, cela n'est tout simplement pas possible de telle sorte qu'il évolue également dans les applications plus grandes. NavigationLinkdans ces petits exemples d'applications, oui - mais pas dès que vous souhaitez réutiliser de nombreuses vues dans une seule application. Et peut-être aussi réutiliser au-delà des limites des modules. (comme: réutiliser View dans iOS, WatchOS, etc ...)

Le problème de conception: les liens de navigation sont codés en dur dans la vue.

NavigationLink(destination: MyCustomView(item: item))

Mais si la vue contenant ceci NavigationLinkdoit être réutilisable, je ne peux pas coder en dur la destination. Il doit y avoir un mécanisme qui fournit la destination. J'ai posé cette question ici et j'ai obtenu une assez bonne réponse, mais toujours pas la réponse complète:

SwiftUI MVVM Coordinator / Router / NavigationLink

L'idée était d'injecter les liens de destination dans la vue réutilisable. En général, l'idée fonctionne, mais malheureusement, cela ne s'adapte pas aux vraies applications de production. Dès que j'ai plusieurs écrans réutilisables, je rencontre le problème logique qu'une vue réutilisable ( ViewA) a besoin d'une vue-destination préconfigurée ( ViewB). Mais que se passe-t-il si vous ViewBavez également besoin d'une destination de vue préconfigurée ViewC? Je besoin de créer ViewBdéjà de telle sorte que l' ViewCon injecte déjà ViewBavant que j'injecter ViewBdans ViewA. Et ainsi de suite .... mais comme les données qui à ce moment doivent être transmises ne sont pas disponibles, la construction entière échoue.

Une autre idée que j'avais était d'utiliser le Environmentmécanisme d'injection comme dépendance pour injecter des destinations NavigationLink. Mais je pense que cela devrait être considéré plus ou moins comme un hack et non comme une solution évolutive pour les grandes applications. Nous finirions par utiliser l'environnement essentiellement pour tout. Mais comme l'environnement ne peut également être utilisé qu'à l' intérieur de View (pas dans des coordinateurs ou des ViewModels séparés), cela créerait à nouveau des constructions étranges à mon avis.

Comme la logique métier (par exemple, voir le code du modèle) et la vue doivent être séparées, la navigation et la vue doivent être séparées (par exemple le modèle de coordinateur). C'est UIKitpossible parce que nous accédons à UIViewControlleret UINavigationControllerderrière la vue. UIKit'sMVC avait déjà le problème de mélanger tellement de concepts qu'il devint le nom amusant "Massive-View-Controller" au lieu de "Model-View-Controller". Maintenant, un problème similaire persiste, SwiftUImais encore pire à mon avis. La navigation et les vues sont fortement couplées et ne peuvent pas être découplées. Il n'est donc pas possible de faire des vues réutilisables si elles contiennent de la navigation. Il était possible de résoudre ce problème, UIKitmais maintenant je ne vois pas de solution sensée dansSwiftUI. Malheureusement, Apple ne nous a pas expliqué comment résoudre de tels problèmes architecturaux. Nous avons juste quelques petits exemples d'applications.

J'adorerais avoir tort. Veuillez me montrer un modèle de conception d'application propre qui résout ce problème pour les grandes applications prêtes pour la production.

Merci d'avance.


Mise à jour: cette prime se terminera dans quelques minutes et malheureusement, personne n'a encore été en mesure de fournir un exemple de travail. Mais je vais commencer une nouvelle prime pour résoudre ce problème si je ne trouve pas d'autre solution et le lier ici. Merci à tous pour leur grande contribution!

Darko
la source
1
D'accord! J'ai créé une demande pour cela dans "Feedback Assistant" il y a plusieurs mois, aucune réponse pour le moment: gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245
Sajjon
@Sajjon Merci! J'ai l'intention d'écrire également sur Apple, voyons si j'obtiens une réponse.
Darko
1
A a écrit une lettre à Apple à ce sujet. Voyons voir si nous obtenons une répétition.
Darko
1
Agréable! Ce serait de loin le meilleur cadeau de la WWDC!
Sajjon

Réponses:

10

La fermeture est tout ce dont vous avez besoin!

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

J'ai écrit un article sur le remplacement du modèle de délégué dans SwiftUI par des fermetures. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/

Mecid
la source
La fermeture est une bonne idée, merci! Mais à quoi cela ressemblerait-il dans une hiérarchie de vues profondes? Imaginez que j'ai une NavigationView qui va 10 niveaux plus en profondeur, en détail, en détail, en détail, etc ...
Darko
Je voudrais vous inviter à montrer un exemple de code simple de seulement trois niveaux de profondeur.
Darko
7

Mon idée serait à peu près une combinaison de Coordinatoret Delegatemodèle. Créez d'abord une Coordinatorclasse:


struct Coordinator {
    let window: UIWindow

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

Adaptez le SceneDelegatepour utiliser 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()
        }
    }

À l'intérieur de ContentView, nous avons ceci:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

Nous pouvons définir le ContenViewDelegateprotocole comme ceci:

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

Itemest juste une structure qui est identifiable, pourrait être autre chose (par exemple id d'un élément comme dans un TableViewdans UIKit)

L'étape suivante consiste à adopter ce protocole Coordinatoret simplement passer la vue que vous souhaitez présenter:

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

Jusqu'à présent, cela a bien fonctionné dans mes applications. J'espère que ça aide.

Nikola Matijevic
la source
Merci pour l'exemple de code. Je voudrais vous inviter à changer Text("Returned Destination1")pour quelque chose comme MyCustomView(item: ItemType, destinationView: View). Il MyCustomViewfaut donc également injecter des données et une destination. Comment résoudriez-vous cela?
Darko
Vous rencontrez le problème d'imbrication que je décris dans mon post. S'il vous plait corrigez moi si je me trompe. Fondamentalement, cette approche fonctionne si vous avez une vue réutilisable et que cette vue réutilisable ne contient pas une autre vue réutilisable avec NavigationLink. Ce qui est un cas d'utilisation assez simple, mais ne s'adapte pas aux grandes applications. (où presque toutes les vues sont réutilisables)
Darko
Cela dépend fortement de la façon dont vous gérez les dépendances de vos applications et de leur flux. Si vous avez des dépendances en un seul endroit, comme vous le devriez IMO (également connu sous le nom de racine de composition), vous ne devriez pas rencontrer ce problème.
Nikola Matijevic
Ce qui fonctionne pour moi, c'est de définir toutes vos dépendances pour une vue en tant que protocole. Ajoutez la conformité au protocole dans la racine de la composition. Passez les dépendances au coordinateur. Injectez-les du coordinateur. En théorie, vous devriez vous retrouver avec plus de trois paramètres, si cela est fait correctement, jamais plus de dependencieset destination.
Nikola Matijevic
1
J'aimerais voir un exemple concret. Comme je l'ai déjà mentionné, commençons par Text("Returned Destination1"). Et si cela devait être un MyCustomView(item: ItemType, destinationView: View). Qu'allez-vous y injecter? Je comprends l'injection de dépendances, les protocoles de couplage lâche et les dépendances partagées avec les coordinateurs. Tout cela n'est pas le problème - c'est l'imbrication nécessaire. Merci.
Darko
2

Quelque chose qui me vient à l'esprit est que lorsque vous dites:

Mais que se passe-t-il si ViewB a également besoin d'un ViewC de destination de vue préconfiguré? Je devrais déjà créer ViewB de telle manière que ViewC soit déjà injecté dans ViewB avant d'injecter ViewB dans ViewA. Et ainsi de suite .... mais comme les données qui à ce moment doivent être transmises ne sont pas disponibles, la construction entière échoue.

ce n'est pas tout à fait vrai. Plutôt que de fournir des vues, vous pouvez concevoir vos composants réutilisables de manière à fournir des fermetures qui fournissent des vues à la demande.

De cette façon, la fermeture qui produit ViewB à la demande peut lui fournir une fermeture qui produit ViewC à la demande, mais la construction réelle des vues peut se produire à un moment où les informations contextuelles dont vous avez besoin sont disponibles.

Sam Deane
la source
Mais en quoi la création d'un tel «arbre de fermeture» diffère-t-elle des vues réelles? L'élément fournissant le problème serait résolu, mais pas l'imbrication nécessaire. Je crée une fermeture qui crée une vue - ok. Mais dans cette fermeture, je devrais déjà fournir la création de la prochaine fermeture. Et dans le dernier le suivant. Etc ... mais peut-être que je vous comprends mal. Un exemple de code serait utile. Merci.
Darko
2

Voici un exemple amusant de recherche infinie et de modification de vos données pour la prochaine vue détaillée par programme

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
la source
-> certaines vues vous obligent à toujours renvoyer un seul type de vue.
Darko
L'injection de dépendances avec EnvironmentObject résout une partie du problème. Mais: quelque chose de crucial et important dans un cadre d'interface utilisateur devrait-il être si complexe ...?
Darko
Je veux dire - si l'injection de dépendance est la seule solution à cela, je l'accepterais à contrecœur. Mais ça sentirait vraiment ...
Darko
1
Je ne vois pas pourquoi vous ne pouvez pas l'utiliser avec votre exemple de framework. Si vous parlez d'un cadre qui propose une vue inconnue, j'imagine qu'il pourrait simplement renvoyer une vue. Je ne serais pas non plus surpris si une AnyView à l'intérieur d'un NavigationLink n'est pas vraiment un gros coup préféré car la vue parent est complètement séparée de la disposition réelle de l'enfant. Je ne suis pas un expert cependant, il faudrait le tester. Au lieu de demander à tout le monde un exemple de code où ils ne peuvent pas comprendre pleinement vos besoins, pourquoi ne pas écrire un exemple UIKit et demander des traductions?
jasongregori
1
Cette conception est essentiellement le fonctionnement de l'application (UIKit) sur laquelle je travaille. Des modèles sont générés qui sont liés à d'autres modèles. Un système central détermine quel vc doit être chargé pour ce modèle, puis le vc parent le pousse sur la pile.
jasongregori
2

J'écris une série d'articles de blog sur la création d'une approche MVP + Coordinators dans SwiftUI qui peut être utile:

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

Le projet complet est disponible sur Github: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

J'essaie de le faire comme s'il s'agissait d'une grosse application en termes d'évolutivité. Je pense avoir résolu le problème de navigation, mais je dois encore voir comment faire des liens profonds, sur lesquels je travaille actuellement. J'espère que ça aide.

Luis Ascorbe
la source
Wow, c'est super, merci! Vous avez fait du bon travail sur la mise en œuvre des coordinateurs dans SwiftUI. L'idée de créer NavigationViewla vue racine est fantastique. C'est de loin la mise en œuvre la plus avancée des coordinateurs SwiftUI que j'ai vue de loin.
Darko
Je voudrais vous attribuer la prime simplement parce que votre solution de coordinateur est vraiment géniale. Le seul problème que j'ai - cela ne résout pas vraiment le problème que je décris. Il se dissocie NavigationLinkmais il le fait en introduisant une nouvelle dépendance couplée. Le MasterViewdans votre exemple ne dépend pas NavigationButton. Imaginez le placement MasterViewdans un package Swift - il ne compilerait plus car le type NavigationButtonest inconnu. De plus, je ne vois pas comment le problème des réutilisables imbriqués Viewsserait résolu par cela?
Darko
Je serais heureux de me tromper, et si je le suis, veuillez me l'expliquer. Même si la prime s'épuise en quelques minutes, j'espère pouvoir vous attribuer en quelque sorte les points. (jamais fait de prime auparavant, mais je pense que je peux simplement créer une question de suivi avec une nouvelle?)
Darko
1

Il s'agit d'une réponse complètement absurde, donc cela se révélera probablement un non-sens, mais je serais tenté d'utiliser une approche hybride.

Utilisez l'environnement pour traverser un seul objet coordinateur - appelons-le NavigationCoordinator.

Donnez à vos vues réutilisables une sorte d'identifiant qui est défini dynamiquement. Cet identifiant fournit des informations sémantiques correspondant au cas d'utilisation réel et à la hiérarchie de navigation de l'application cliente.

Demandez aux vues réutilisables d'interroger le NavigationCoordinator pour la vue de destination, en passant leur identifiant et l'identifiant du type de vue vers lequel ils naviguent.

Cela laisse le NavigationCoordinator comme un seul point d'injection, et c'est un objet non-vue auquel on peut accéder en dehors de la hiérarchie de vue.

Pendant la configuration, vous pouvez enregistrer les bonnes classes de vue pour qu'elles soient renvoyées, en utilisant une sorte de correspondance avec les identifiants transmis lors de l'exécution. Quelque chose d'aussi simple que la correspondance avec l'identifiant de destination peut fonctionner dans certains cas. Ou une correspondance avec une paire d'identifiants d'hôte et de destination.

Dans les cas plus complexes, vous pouvez écrire un contrôleur personnalisé qui prend en compte d'autres informations spécifiques à l'application.

Puisqu'il est injecté via l'environnement, n'importe quelle vue peut remplacer le NavigationCoordinator par défaut à tout moment et en fournir un autre à ses sous-vues.

Sam Deane
la source