web-dev-qa-db-fra.com

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

J'essaie de faire l'architecture pour une application SwiftUI plus grande et prête pour la production. Je suis constamment confronté au même problème, ce qui indique une faille de conception majeure dans SwiftUI.

Personne ne pouvait encore me donner une réponse complète et opérationnelle.

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

Comme le SwiftUINavigationLink est fortement lié à la vue, cela n'est tout simplement pas possible de telle sorte qu'il évolue également dans les applications plus grandes. NavigationLink dans ces petits exemples d'applications fonctionne, oui - mais pas dès que vous souhaitez réutiliser plusieurs vues dans une application. Et peut-être aussi réutiliser au-delà des limites du module. (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 ce NavigationLink doit ê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:

Coordinateur/routeur/lien de navigation SwiftUI MVVM

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) nécessite une vue-destination préconfigurée (ViewB). Mais que se passe-t-il si ViewB a également besoin d'une destination de vue préconfigurée ViewC? J'aurais besoin de créer ViewB déjà 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-là doivent être transmises ne sont pas disponibles, la construction entière échoue.

Une autre idée que j'ai eue était d'utiliser le Environment comme mécanisme d'injection de dépendances pour injecter des destinations pour 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 peut également être utilisé uniquement à l'intérieur de View (pas dans des Coordinators ou ViewModels séparés), cela créerait à nouveau des constructions étranges à mon avis.

Comme la logique métier (par exemple, le code du modèle de vue) et la vue doivent être séparés, la navigation et la vue doivent également être séparées (par exemple le modèle Coordinator) Dans UIKit c'est possible parce que nous accédons à UIViewController et UINavigationController derrière la vue. UIKit's MVC avait déjà le problème qu'il mélangeait tellement de concepts qu'il est devenu le nom amusant "Massive-View-Controller" au lieu de "Model-View-Controller". Maintenant, un problème similaire persiste dans SwiftUI mais encore pire à mon avis. La navigation et les vues sont fortement couplées et ne peuvent pas être découplées. Par conséquent, il n'est pas possible de créer des vues réutilisables si elles contiennent de la navigation. Il était possible de résoudre cela dans UIKit mais maintenant je ne vois pas de solution sensée dans SwiftUI. Malheureusement Apple ne nous a pas fourni d'explication sur la manière de résoudre des problèmes d'architecture comme celui-là. Nous n'avons que quelques 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 encore personne n'a pu fournir d'exemple fonctionnel. Mais je vais commencer une nouvelle prime pour résoudre ce problème si je ne trouve aucune autre solution et la lier ici. Merci à tous pour leur belle contribution!


Mise à jour du 18 juin 2020: j'ai eu une réponse de Apple concernant ce problème, proposant quelque chose comme ceci pour découpler les vues et les modèles:

enum Destination {
  case viewA
  case viewB 
  case viewC
}

struct Thing: Identifiable {
  var title: String
  var destination: Destination
  // … other stuff omitted …
}

struct ContentView {
  var things: [Thing]

  var body: some View {
    List(things) {
      NavigationLink($0.title, destination: destination(for: $0))
    }
  }

  @ViewBuilder
  func destination(for thing: Thing) -> some View {
    switch thing.destination {
      case .viewA:
        return ViewA(thing)
      case .viewB:
        return ViewB(thing)
      case .viewC:
        return ViewC(thing)
    }
  }
}

Ma réponse a été:

Merci pour les commentaires. Mais comme vous le voyez, vous avez toujours le couplage fort dans la vue. Désormais, "ContentView" doit connaître toutes les vues (ViewA, ViewB, ViewC) dans lesquelles il peut également naviguer. Comme je l'ai dit, cela fonctionne dans de petits exemples d'applications, mais cela ne s'adapte pas aux grandes applications prêtes pour la production.

Imaginez que je crée une vue personnalisée dans un projet dans GitHub. Et puis importez cette vue dans mon application. Cette vue personnalisée ne sait rien des autres vues dans lesquelles elle peut également naviguer, car elles sont spécifiques à mon application.

J'espère avoir mieux expliqué le problème.

La seule solution propre que je vois à ce problème est de séparer la navigation et les vues comme dans UIKit. (par exemple, UINavigationController)

Merci, Darko

Donc toujours pas de solution propre et fonctionnelle pour ce problème. Dans l'attente de la WWDC 2020.


50
Darko

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 si ce serait une grosse application en termes d'évolutivité. Je pense avoir réglé le problème de navigation, mais je dois encore voir comment faire des liens profonds, ce sur quoi je travaille actuellement. J'espère que cela aide.

2
Luis Ascorbe

C'est une réponse complètement décalée, donc cela s'avérera probablement absurde, mais je serais tenté d'utiliser une approche hybride.

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

Donnez à vos vues réutilisables une sorte d'identifiant qui est défini dynamiquement. Cet identifiant donne 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 transmettant leur identifiant et l'identificateur du type de vue vers lequel elles naviguent.

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

Pendant l'installation, vous pouvez enregistrer les bonnes classes de vue pour qu'il retourne, en utilisant une sorte de correspondance avec les identificateurs qu'il a transmis au moment 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'identificateurs 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 différent à ses sous-vues.

1
Sam Deane

Le problème est dans la vérification de type statique, ce est à dire. pour construire NavigationLink nous devons lui fournir des vues spécifiques. Donc, si nous devons briser ces dépendances, nous avons besoin d'un effacement de type, c'est-à-dire. AnyView

Voici une démonstration de travail de l'idée, basée sur les concepts Router/ViewModel utilisant des vues effacées de type pour éviter les dépendances étroites. Testé avec Xcode 11.4/iOS 13.4.

Commençons par la fin de ce que nous obtenons et analysons-le (dans les commentaires):

struct DemoContainerView: View {
    var router: Router       // some router
    var vm: [RouteModel]     // some view model having/being route model

    var body: some View {
        RouteContainer(router: router) {    // route container with UI layout
          List {
            ForEach(self.vm.indices, id: \.self) {
              Text("Label \($0)")
                .routing(with: self.vm[$0])    // modifier giving UI element
                                               // possibility to route somewhere
                                               // depending on model
            }
          }
        }
    }
}

struct TestRouter_Previews: PreviewProvider {
    static var previews: some View {
        DemoContainerView(router: SimpleRouter(), 
            vm: (1...10).map { SimpleViewModel(text: "Item \($0)") })
    }
}

Ainsi, nous avons une interface utilisateur pure sans détails de navigation et une connaissance séparée de l'endroit où cette interface utilisateur peut être acheminée. Et voici comment cela fonctionne:

demo

Blocs de construction:

// Base protocol for route model
protocol RouteModel {}  

// Base protocol for router
protocol Router {
    func destination(for model: RouteModel) -> AnyView
}

// Route container wrapping NavigationView and injecting router
// into view hierarchy
struct RouteContainer<Content: View>: View {
    let router: Router?

    private let content: () -> Content
    init(router: Router? = nil, @ViewBuilder _ content: @escaping () -> Content) {
        self.content = content
        self.router = router
    }

    var body: some View {
        NavigationView {
            content()
        }.environment(\.router, router)
    }
}

// Modifier making some view as routing element by injecting
// NavigationLink with destination received from router based
// on some model
struct RouteModifier: ViewModifier {
    @Environment(\.router) var router
    var rm: RouteModel

    func body(content: Content) -> some View {
        Group {
            if router == nil {
                content
            } else {
                NavigationLink(destination: router!.destination(for: rm)) { content }
            }
        }
    }
}

// standard view extension to use RouteModifier
extension View {
    func routing(with model: RouteModel) -> some View {
        self.modifier(RouteModifier(rm: model))
    }
}

// Helper environment key to inject Router into view hierarchy
struct RouterKey: EnvironmentKey {
    static let defaultValue: Router? = nil
}

extension EnvironmentValues {
    var router: Router? {
        get { self[RouterKey.self] }
        set { self[RouterKey.self] = newValue }
    }
}

Code de test affiché dans la démo:

protocol SimpleRouteModel: RouteModel {
    var next: AnyView { get }
}

class SimpleViewModel: ObservableObject {
    @Published var text: String
    init(text: String) {
        self.text = text
    }
}

extension SimpleViewModel: SimpleRouteModel {
    var next: AnyView {
        AnyView(DemoLevel1(rm: self))
    }
}

class SimpleEditModel: ObservableObject {
    @Published var vm: SimpleViewModel
    init(vm: SimpleViewModel) {
        self.vm = vm
    }
}

extension SimpleEditModel: SimpleRouteModel {
    var next: AnyView {
        AnyView(DemoLevel2(em: self))
    }
}

class SimpleRouter: Router {
    func destination(for model: RouteModel) -> AnyView {
        guard let simpleModel = model as? SimpleRouteModel else {
            return AnyView(EmptyView())
        }
        return simpleModel.next
    }
}

struct DemoLevel1: View {
    @ObservedObject var rm: SimpleViewModel

    var body: some View {
        VStack {
            Text("Details: \(rm.text)")
            Text("Edit")
                .routing(with: SimpleEditModel(vm: rm))
        }
    }
}

struct DemoLevel2: View {
    @ObservedObject var em: SimpleEditModel

    var body: some View {
        HStack {
            Text("Edit:")
            TextField("New value", text: $em.vm.text)
        }
    }
}

struct DemoContainerView: View {
    var router: Router
    var vm: [RouteModel]

    var body: some View {
        RouteContainer(router: router) {
            List {
                ForEach(self.vm.indices, id: \.self) {
                    Text("Label \($0)")
                        .routing(with: self.vm[$0])
                }
            }
        }
    }
}

// MARK: - Preview
struct TestRouter_Previews: PreviewProvider {
    static var previews: some View {
        DemoContainerView(router: SimpleRouter(), vm: (1...10).map { SimpleViewModel(text: "Item \($0)") })
    }
}
0
Asperi