SwiftUI: ¿cómo evitar la navegación codificada en la vista?

33

Intento hacer la arquitectura para una aplicación SwiftUI más grande y lista para producción. Me encuentro todo el tiempo con el mismo problema que apunta a una falla de diseño importante en SwiftUI.

Todavía nadie podía darme una respuesta completa, lista para la producción.

¿Cómo hacer vistas reutilizables SwiftUIque contengan navegación?

Como SwiftUI NavigationLinkestá fuertemente vinculado a la vista, esto simplemente no es posible de tal manera que se amplíe también en aplicaciones más grandes. NavigationLinken esas pequeñas aplicaciones de muestra funciona, sí, pero no tan pronto como quiera reutilizar muchas vistas en una aplicación. Y tal vez también reutilizar sobre los límites del módulo. (como: reutilizar View en iOS, WatchOS, etc.)

El problema de diseño: los enlaces de navegación están codificados en la vista.

NavigationLink(destination: MyCustomView(item: item))

Pero si la vista que contiene esto NavigationLinkes reutilizable, no puedo codificar el destino. Tiene que haber un mecanismo que proporcione el destino. Pregunté esto aquí y obtuve una respuesta bastante buena, pero aún no es la respuesta completa:

Coordinador / Enrutador / NavigationLink de SwiftUI MVVM

La idea era inyectar los enlaces de destino en la vista reutilizable. En general, la idea funciona, pero desafortunadamente no se adapta a las aplicaciones de producción reales. Tan pronto como tengo varias pantallas reutilizables, me encuentro con el problema lógico de que una vista reutilizable ( ViewA) necesita un destino de vista preconfigurado ( ViewB). Pero, ¿y si ViewBtambién necesita un destino de vista preconfigurado ViewC? Yo tendría que crear ViewBya de tal manera que ViewCya se inyecta en ViewBantes de inyectar ViewBen ViewA. Y así sucesivamente ... pero como los datos que en ese momento deben pasarse no están disponibles, la construcción completa falla.

Otra idea que tuve fue utilizar el Environmentmecanismo de inyección como dependencia para inyectar destinos NavigationLink. Pero creo que esto debería considerarse más o menos como un truco y no como una solución escalable para aplicaciones grandes. Terminaríamos usando el entorno básicamente para todo. Pero debido a que el entorno también se puede usar solo dentro de las vistas (no en coordinadores o modelos de vista separados), esto crearía construcciones extrañas en mi opinión.

Al igual que la lógica de negocios (por ejemplo, ver el código del modelo) y la vista deben estar separadas, también la navegación y la vista deben estar separadas (por ejemplo, el patrón Coordinador). UIKitEs posible porque accedemos a la vista UIViewControllery UINavigationControllerdetrás de ella. UIKit'sMVC ya tenía el problema de que combinaba tantos conceptos que se convirtió en el nombre divertido "Massive-View-Controller" en lugar de "Model-View-Controller". Ahora continúa un problema similar SwiftUIpero peor en mi opinión. La navegación y las vistas están fuertemente acopladas y no se pueden desacoplar. Por lo tanto, no es posible hacer vistas reutilizables si contienen navegación. Fue posible resolver esto en UIKitpero ahora no puedo ver una solución sensata enSwiftUI. Desafortunadamente, Apple no nos dio una explicación sobre cómo resolver problemas de arquitectura como ese. Tenemos solo algunas pequeñas aplicaciones de muestra.

Me encantaría que me demuestren lo contrario. Muéstrame un patrón de diseño de aplicaciones limpio que resuelva esto para aplicaciones grandes listas para producción

Gracias por adelantado.


Actualización: esta recompensa terminará en unos minutos y desafortunadamente aún nadie pudo proporcionar un ejemplo de trabajo. Pero comenzaré una nueva recompensa para resolver este problema si no puedo encontrar otra solución y vincularla aquí. ¡Gracias a todos por su gran contribución!

Darko
fuente
1
¡Convenido! Creé
Sajjon
@Sajjon Gracias! Tengo la intención de escribir Apple también, veamos si recibo una respuesta.
Darko
1
A escribió una carta a Apple con respecto a esto. Veamos si tenemos una respuesta.
Darko
1
¡Agradable! ¡Sería el mejor regalo durante la WWDC con diferencia!
Sajjon

Respuestas:

10

¡El cierre es todo lo que necesitas!

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

Escribí una publicación sobre cómo reemplazar el patrón de delegado en SwiftUI con cierres. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/

Mecida
fuente
El cierre es una buena idea, gracias! ¿Pero cómo se vería eso en una jerarquía de visión profunda? Imagina que tengo un NavigationView que va 10 niveles más profundo, detalle, detalle, detalle, etc ...
Darko
Me gustaría invitarle a mostrar un código de ejemplo simple de solo tres niveles de profundidad.
Darko
7

Mi idea sería más o menos ser una combinación de Coordinatory Delegatepatrón. Primero, crea una Coordinatorclase:


struct Coordinator {
    let window: UIWindow

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

Adapte el SceneDelegatepara usar el 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()
        }
    }

Dentro de ContentView, tenemos esto:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

Podemos definir el ContenViewDelegateprotocolo de esta manera:

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

Donde Itemes solo una estructura que es identificable, podría ser cualquier otra cosa (por ejemplo, la identificación de algún elemento como en un TableViewen UIKit)

El siguiente paso es adoptar este protocolo Coordinatory simplemente pasar la vista que desea presentar:

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

Hasta ahora, esto ha funcionado bien en mis aplicaciones. Espero que ayude.

Nikola Matijevic
fuente
Gracias por el código de muestra. Me gustaría invitarlos a cambiar Text("Returned Destination1")a algo así MyCustomView(item: ItemType, destinationView: View). Entonces eso MyCustomViewtambién necesita algunos datos y destino inyectado. ¿Cómo resolverías eso?
Darko
Te encuentras con el problema de anidación que describo en mi publicación. Por favor, corríjame si estoy equivocado. Básicamente, este enfoque funciona si tiene una vista reutilizable y esa vista reutilizable no contiene otra vista reutilizable con NavigationLink. Es un caso de uso bastante simple pero no escala a grandes aplicaciones. (donde casi todas las vistas son reutilizables)
Darko
Esto depende en gran medida de cómo gestione las dependencias de su aplicación y su flujo. Si tiene dependencias en un solo lugar, como debería IMO (también conocido como composición raíz), no debería encontrarse con este problema.
Nikola Matijevic
Lo que funciona para mí es definir todas sus dependencias para una vista como protocolo. Agregue conformidad al protocolo en la raíz de la composición. Pase dependencias al coordinador. Inyéctelos del coordinador. En teoría, debería terminar con más de tres parámetros, si se realiza correctamente, nunca más que dependenciesy destination.
Nikola Matijevic
1
Me encantaría ver un ejemplo concreto. Como ya mencioné, comencemos por Text("Returned Destination1"). ¿Qué pasa si esto necesita ser a MyCustomView(item: ItemType, destinationView: View). ¿Qué vas a inyectar allí? Entiendo la inyección de dependencia, el acoplamiento flojo a través de protocolos y las dependencias compartidas con los coordinadores. Todo eso no es el problema, es el anidamiento necesario. Gracias.
Darko
2

Algo que se me ocurre es que cuando dices:

Pero, ¿qué sucede si ViewB también necesita un ViewC de destino de vista preconfigurado? Necesitaría crear ViewB ya de tal manera que ViewC ya se inyecte en ViewB antes de inyectar ViewB en ViewA. Y así sucesivamente ... pero como los datos que en ese momento deben pasarse no están disponibles, la construcción completa falla.

No es del todo cierto. En lugar de proporcionar vistas, puede diseñar sus componentes reutilizables para que proporcione cierres que ofrezcan vistas a pedido.

De esa manera, el cierre que produce ViewB a pedido puede proporcionarle un cierre que produce ViewC a pedido, pero la construcción real de las vistas puede ocurrir en un momento en que la información contextual que necesita está disponible.

Sam Deane
fuente
Pero, ¿cómo difiere la creación de tal "árbol de cierre" de las vistas reales? El problema que proporciona el elemento se resolvería, pero no la anidación necesaria. Creo un cierre que crea una vista, está bien. Pero en ese cierre ya tendría que proporcionar la creación del próximo cierre. Y en el último el siguiente. Etc ... pero tal vez te malinterprete. Algún ejemplo de código ayudaría. Gracias.
Darko
2

Aquí hay un ejemplo divertido de profundizar infinitamente y cambiar sus datos para la siguiente vista detallada mediante programación

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
fuente
-> alguna Vista te obliga a devolver siempre un solo tipo de Vista.
Darko
La inyección de dependencia con EnvironmentObject resuelve una parte del problema. Pero: ¿debería algo tan crucial e importante en un marco de UI ser tan complejo ...?
Darko
Quiero decir, si la inyección de dependencia es la única solución para esto, entonces lo aceptaría a regañadientes. Pero esto realmente olería ...
Darko
1
No veo por qué no podría usar esto con su ejemplo de marco. Si está hablando de un marco que vende una vista desconocida, me imagino que podría devolver algo de Vista. Tampoco me sorprendería que un AnyView dentro de un NavigationLink no sea realmente un golpe de preferencia, ya que la vista principal está completamente separada del diseño real del elemento secundario. Sin embargo, no soy un experto, tendría que ser probado. En lugar de pedirles a todos un código de muestra donde no puedan entender completamente sus requisitos, ¿por qué no escriben una muestra de UIKit y solicitan traducciones?
jasongregori
1
Este diseño es básicamente cómo funciona la aplicación (UIKit) en la que trabajo. Se generan modelos que enlazan con otros modelos. Un sistema central determina qué vc debe cargarse para ese modelo y luego el vc principal lo empuja a la pila.
jasongregori
2

Estoy escribiendo una serie de publicaciones de blog sobre la creación de un enfoque de Coordinadores MVP + en SwiftUI que puede ser útil:

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

El proyecto completo está disponible en Github: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

Estoy tratando de hacerlo como si fuera una gran aplicación en términos de escalabilidad. Creo que he resuelto el problema de navegación, pero todavía tengo que ver cómo hacer enlaces profundos, que es en lo que estoy trabajando actualmente. Espero que ayude.

Luis Ascorbe
fuente
Wow, eso es genial, gracias! Hiciste un buen trabajo implementando Coordinadores en SwiftUI. La idea de hacer NavigationViewla vista raíz es fantástica. Esta es, con mucho, la implementación más avanzada de Coordinadores SwiftUI que vi con diferencia.
Darko
Me gustaría otorgarle la recompensa solo porque su solución de Coordinador es realmente excelente. El único problema que tengo es que realmente no aborda el problema que describo. Se desacopla NavigationLinkpero lo hace introduciendo una nueva dependencia acoplada. El MasterViewen su ejemplo no depende de NavigationButton. Imagine colocarlo MasterViewen un paquete Swift: ya no se compilaría porque el tipo NavigationButtones desconocido. Además, no veo cómo se resolvería el problema de la reutilización anidada Views.
Darko
Me encantaría estar equivocado, y si lo estoy, explícamelo. Aunque la recompensa se agota en unos minutos, espero poder otorgarle de alguna manera los puntos. (¿Nunca hice una recompensa antes, pero creo que puedo crear una pregunta de seguimiento con una nueva?)
Darko
1

Esta es una respuesta completamente descabellada, por lo que probablemente resulte absurdo, pero me vería tentado a utilizar un enfoque híbrido.

Use el entorno para pasar a través de un único objeto coordinador, llamémoslo NavigationCoordinator.

Proporcione a sus vistas reutilizables algún tipo de identificador que se configure dinámicamente. Este identificador proporciona información semántica correspondiente al caso de uso real de la aplicación del cliente y la jerarquía de navegación.

Haga que las vistas reutilizables consulten al Coordinador de Navegación para la vista de destino, pasando su identificador y el identificador del tipo de vista al que están navegando.

Esto deja a NavigationCoordinator como un único punto de inyección, y es un objeto sin vista al que se puede acceder fuera de la jerarquía de vista.

Durante la configuración, puede registrar las clases de vista correctas para que regrese, utilizando algún tipo de coincidencia con los identificadores que se pasan en tiempo de ejecución. Algo tan simple como coincidir con el identificador de destino podría funcionar en algunos casos. O coincidir con un par de identificadores de host y destino.

En casos más complejos, puede escribir un controlador personalizado que tenga en cuenta otra información específica de la aplicación.

Dado que se inyecta a través del entorno, cualquier vista puede anular el Coordinador de navegación predeterminado en cualquier punto y proporcionar uno diferente a sus subvistas.

Sam Deane
fuente