SwiftUI - come evitare la navigazione hardcoded nella vista?


33

Cerco di realizzare l'architettura per un'app SwiftUI più grande e pronta per la produzione. Sto correndo continuamente nello stesso problema che indica un grosso difetto di progettazione in SwiftUI.

Ancora nessuno poteva darmi una risposta completa, pronta per la produzione.

Come fare visualizzazioni riutilizzabili in SwiftUIcui contiene la navigazione?

Dato che SwiftUI NavigationLinkè fortemente legato alla vista, ciò non è semplicemente possibile in modo tale da ridimensionarsi anche in App più grandi. NavigationLinkin quelle piccole app di esempio funziona, sì, ma non appena si desidera riutilizzare molte visualizzazioni in un'unica app. E forse anche riutilizzare oltre i confini del modulo. (come: riutilizzare Visualizza in iOS, WatchOS, ecc ...)

Il problema di progettazione: i NavigationLink sono codificati nella vista.

NavigationLink(destination: MyCustomView(item: item))

Ma se la vista che contiene questo NavigationLinkdovesse essere riutilizzabile, non posso codificare la destinazione. Ci deve essere un meccanismo che fornisce la destinazione. Ho chiesto questo qui e ho ottenuto una buona risposta, ma ancora non la risposta completa:

SwiftUI MVVM Coordinator / Router / NavigationLink

L'idea era quella di iniettare i collegamenti di destinazione nella vista riutilizzabile. Generalmente l'idea funziona, ma sfortunatamente questo non si adatta alle vere app di produzione. Non appena ho più schermate riutilizzabili mi imbatto nel problema logico che una vista riutilizzabile ( ViewA) necessita di una vista-destinazione preconfigurata ( ViewB). E se fosse ViewBnecessaria anche una destinazione vista preconfigurata ViewC? Avrei bisogno di creare ViewBgià in modo tale che ViewCviene iniettato già ViewBprima di iniettare ViewBin ViewA. E così via .... ma poiché i dati che a quel tempo devono essere passati non sono disponibili, l'intero costrutto fallisce.

Un'altra idea che ho avuto è stata quella di utilizzare il Environmentmeccanismo di iniezione di dipendenza per iniettare destinazioni NavigationLink. Ma penso che questo dovrebbe essere considerato più o meno come un hack e non una soluzione scalabile per grandi app. Finiremmo per usare l'ambiente praticamente per tutto. Ma poiché anche l'ambiente può essere utilizzato solo all'interno di View (non in coordinatori o ViewModels separati), secondo me ciò creerebbe di nuovo strane costruzioni.

Come la logica aziendale (ad esempio il codice del modello di visualizzazione) e la vista devono essere separate, anche la navigazione e la vista devono essere separate (ad esempio il modello di coordinatore). UIKitÈ possibile perché accediamo alla vista UIViewControllere UINavigationControllerdietro la vista. UIKit'sMVC aveva già il problema di aver combinato così tanti concetti da diventare il nome divertente "Massive-View-Controller" anziché "Model-View-Controller". Ora un problema simile continua SwiftUIma, a mio avviso, anche peggio. La navigazione e le viste sono fortemente accoppiate e non possono essere disaccoppiate. Pertanto non è possibile eseguire visualizzazioni riutilizzabili se contengono navigazione. È stato possibile risolvere questo problema, UIKitma ora non riesco a vedere una soluzione sana inSwiftUI. Purtroppo Apple non ci ha fornito una spiegazione su come risolvere problemi di architettura del genere. Abbiamo solo alcune piccole app di esempio.

Mi piacerebbe essere smentito. Vi prego di mostrarmi un modello di progettazione di app pulito che risolva questo problema per le app di grande produzione pronte.

Grazie in anticipo.


Aggiornamento: questa taglia finirà tra pochi minuti e purtroppo nessuno è stato in grado di fornire un esempio funzionante. Ma inizierò una nuova ricompensa per risolvere questo problema se non riesco a trovare un'altra soluzione e collegarla qui. Grazie a tutti per l'ottimo contributo!


1
Concordato! Ho creato una richiesta per questo in "Feedback Assistant" molti mesi fa, nessuna risposta ancora: gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245
Sajjon

@Sajjon Grazie! Ho intenzione di scrivere anche Apple, vediamo se ho una risposta.
Darko,

1
A ha scritto una lettera ad Apple in merito. Vediamo se otteniamo una risposta.
Darko,

1
Bello! Sarebbe di gran lunga il miglior regalo durante il WWDC!
Sajjon,

Risposte:


10

La chiusura è tutto ciò che serve!

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

Ho scritto un post sulla sostituzione del modello delegato in SwiftUI con chiusure. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/


La chiusura è una buona idea, grazie! Ma come sarebbe simile in una gerarchia di visione profonda? Immagina di avere un NavigationView che approfondisce 10 livelli, dettagli, dettagli, dettagli, ecc ...
Darko,

Vorrei invitarti a mostrare un semplice codice di esempio di soli tre livelli di profondità.
Darko,

7

La mia idea sarebbe praticamente una combinazione di Coordinatore Delegatemodello. Innanzitutto, crea una Coordinatorclasse:


struct Coordinator {
    let window: UIWindow

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

Adatta il SceneDelegateper usare il 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()
        }
    }

All'interno di ContentView, abbiamo questo:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

Possiamo definire il ContenViewDelegateprotocollo in questo modo:

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

Dove Itemè solo una struttura che è identificabile, potrebbe essere qualcos'altro (es. Id di un elemento come in TableViewa UIKit)

Il prossimo passo è adottare questo protocollo Coordinatore passare semplicemente la vista che si desidera presentare:

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

Finora ha funzionato bene nelle mie app. Spero possa essere d'aiuto.


Grazie per il codice di esempio. Vorrei invitarti a cambiare Text("Returned Destination1")in qualcosa del genere MyCustomView(item: ItemType, destinationView: View). Quindi MyCustomViewè necessario iniettare anche alcuni dati e la destinazione. Come lo risolveresti?
Darko,

Ti imbatti nel problema della nidificazione che descrivo nel mio post. Perfavore, correggimi se sbaglio. Fondamentalmente questo approccio funziona se hai una vista riutilizzabile e quella vista riutilizzabile non contiene un'altra vista riutilizzabile con NavigationLink. Che è un caso d'uso piuttosto semplice ma non si adatta alle grandi app. (dove quasi ogni vista è riutilizzabile)
Darko

Questo dipende fortemente da come gestisci le dipendenze della tua app e dal loro flusso. Se hai dipendenze in un unico posto, come dovresti usare IMO (noto anche come root di composizione), non dovresti incontrare questo problema.
Nikola Matijevic,

Ciò che funziona per me è definire tutte le tue dipendenze per una vista come protocollo. Aggiungi conformità al protocollo nella radice della composizione. Passare le dipendenze al coordinatore. Iniettarli dal coordinatore. In teoria, dovresti finire con più di tre parametri, se fatto correttamente mai più di dependenciese destination.
Nikola Matijevic,

1
Mi piacerebbe vedere un esempio concreto. Come ho già detto, iniziamo da Text("Returned Destination1"). E se questo dovesse essere un MyCustomView(item: ItemType, destinationView: View). Che cosa hai intenzione di iniettare lì? Comprendo l'iniezione delle dipendenze, l'accoppiamento flessibile attraverso i protocolli e le dipendenze condivise con i coordinatori. Tutto ciò non è il problema: è la nidificazione necessaria. Grazie.
Darko,

2

Qualcosa che mi viene in mente è che quando dici:

Ma cosa succede se ViewB necessita anche di una ViewC di destinazione vista preconfigurata? Avrei bisogno di creare ViewB già in modo tale che ViewC sia iniettato già in ViewB prima di iniettare ViewB in ViewA. E così via .... ma poiché i dati che a quel tempo devono essere passati non sono disponibili, l'intero costrutto fallisce.

non è del tutto vero. Invece di fornire viste, è possibile progettare i componenti riutilizzabili in modo da fornire chiusure che forniscono viste su richiesta.

In questo modo la chiusura che produce ViewB su richiesta può fornirle una chiusura che produce ViewC su richiesta, ma la costruzione effettiva delle viste può avvenire in un momento in cui sono disponibili le informazioni contestuali necessarie.


Ma in che modo la creazione di tale "albero di chiusura" differisce dalle visioni reali? L'elemento che fornisce il problema sarebbe risolto, ma non la nidificazione necessaria. Creo una chiusura che crea una vista - ok. Ma in quella chiusura avrei già bisogno di fornire la creazione della prossima chiusura. E nell'ultimo il prossimo. Ecc ... ma forse ti fraintendo. Qualche esempio di codice sarebbe d'aiuto. Grazie.
Darko,

2

Ecco un divertente esempio di drill down infinito e modifica dei dati per la successiva visualizzazione dettagliata a livello di codice

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

-> alcune viste ti costringono a restituire sempre solo un tipo di vista.
Darko,

L'iniezione di dipendenza con EnvironmentObject risolve una parte del problema. Ma: qualcosa di cruciale e importante in un framework UI dovrebbe essere così complesso ...?
Darko,

Voglio dire, se l'iniezione di dipendenza fosse l' unica soluzione per questo, la accetterei con riluttanza. Ma questo avrebbe davvero un odore ...
Darko,

1
Non vedo perché non potresti usarlo con il tuo esempio di framework. Se stai parlando di un framework che vende una vista sconosciuta, immagino che potrebbe semplicemente restituire una vista. Inoltre, non sarei sorpreso se un AnyView all'interno di un NavigationLink non è in realtà un tale hit pref poiché la vista padre è completamente separata dal layout effettivo del bambino. Non sono un esperto, però, dovrebbe essere testato. Invece di chiedere a tutti un codice di esempio in cui non sono in grado di comprendere appieno le tue esigenze, perché non scrivi un esempio di UIKit e chiedi traduzioni?
jasongregori,

1
Questo design è fondamentalmente come funziona l'app (UIKit) su cui lavoro. Vengono generati modelli che collegano ad altri modelli. Un sistema centrale determina quale vc deve essere caricato per quel modello e quindi il vc padre lo inserisce nello stack.
jasongregori,

2

Sto scrivendo una serie di post sul blog sulla creazione di un approccio Coordinatori MVP + in SwiftUI che può essere utile:

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

Il progetto completo è disponibile su Github: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

Sto cercando di farlo come se fosse una grande app in termini di scalabilità. Penso di aver risolto il problema di navigazione, ma devo ancora vedere come eseguire il deep linking, che è ciò su cui sto attualmente lavorando. Spero possa essere d'aiuto.


Wow, fantastico, grazie! Hai svolto un ottimo lavoro sull'attuazione dei coordinatori in SwiftUI. L'idea di rendere NavigationViewla vista radice è fantastica. Questa è di gran lunga la più avanzata implementazione dei coordinatori SwiftUI che ho visto di gran lunga.
Darko

Vorrei conferirti la generosità solo perché la tua soluzione di coordinatore è davvero eccezionale. L'unico problema che ho - non risolve davvero il problema che descrivo. Disaccoppia NavigationLinkma lo fa introducendo una nuova dipendenza accoppiata. Nel MasterViewtuo esempio non dipende NavigationButton. Immagina di MasterViewinserirli in un pacchetto Swift: non verrà più compilato perché il tipo NavigationButtonè sconosciuto. Inoltre non vedo come risolverebbe il problema del riutilizzabile nidificato Views?
Darko

Sarei felice di sbagliarmi, e se lo sono, per favore, spiegamelo. Anche se la ricompensa si esaurisce in pochi minuti spero di poterti assegnare i punti in qualche modo. (non hai mai fatto una taglia prima, ma penso di poter semplicemente creare una domanda di follow-up con una nuova?)
Darko

1

Questa è una risposta completamente fuori dal comune, quindi probabilmente si rivelerà una sciocchezza, ma sarei tentato di usare un approccio ibrido.

Utilizzare l'ambiente per passare attraverso un singolo oggetto coordinatore: chiamiamolo NavigationCoordinator.

Dai alle tue visualizzazioni riutilizzabili una sorta di identificatore impostato dinamicamente. Questo identificatore fornisce informazioni semantiche corrispondenti al caso d'uso effettivo dell'applicazione e alla gerarchia di navigazione.

Chiedi alle visualizzazioni riutilizzabili di interrogare NavigationCoordinator per la vista di destinazione, passando il loro identificatore e l'identificatore del tipo di vista a cui stanno navigando.

Ciò lascia NavigationCoordinator come un singolo punto di iniezione ed è un oggetto non visibile a cui è possibile accedere al di fuori della gerarchia della vista.

Durante l'installazione è possibile registrare le classi di visualizzazione corrette affinché possano essere restituite, utilizzando una sorta di corrispondenza con gli identificatori trasmessi in fase di esecuzione. Qualcosa di semplice come la corrispondenza con l'identificatore di destinazione potrebbe funzionare in alcuni casi. O la corrispondenza con una coppia di identificatori host e destinazione.

In casi più complessi puoi scrivere un controller personalizzato che tenga conto di altre informazioni specifiche dell'app.

Dal momento che viene iniettato tramite l'ambiente, qualsiasi vista può sovrascrivere il NavigationCoordinator predefinito in qualsiasi momento e fornire uno diverso ai suoi sottoview.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.