SwiftUI - como evitar a navegação codificada na exibição?


33

Eu tento fazer a arquitetura para obter um SwiftUI App maior e pronto para produção. Estou correndo o tempo todo com o mesmo problema que aponta para uma falha de design importante no SwiftUI.

Ainda assim, ninguém poderia me dar uma resposta completa, pronta para produção.

Como fazer vistas reutilizáveis ​​nas SwiftUIquais contêm navegação?

Como o SwiftUI NavigationLinkaplicativo está fortemente vinculado à visualização, isso simplesmente não é possível, de forma que ele também é dimensionado em aplicativos maiores. NavigationLinknessas pequenas amostras de aplicativos, sim - mas não assim que você deseja reutilizar muitas visualizações em um aplicativo. E talvez também reutilize além dos limites do módulo. (como: reutilizar a exibição no iOS, WatchOS etc.)

O problema do design: os NavigationLinks são codificados na Visualização.

NavigationLink(destination: MyCustomView(item: item))

Mas se a exibição que contém isso puderNavigationLink ser reutilizada, não será possível codificar o destino. Tem que haver um mecanismo que forneça o destino. Perguntei isso aqui e obtive uma resposta muito boa, mas ainda não a resposta completa:

Coordenador SwiftUI MVVM / Roteador / NavigationLink

A idéia era injetar os Links de destino na exibição reutilizável. Geralmente, a ideia funciona, mas infelizmente isso não é dimensionado para aplicativos de produção reais. Assim que tenho várias telas reutilizáveis, encontro o problema lógico de que uma view reutilizável ( ViewA) precisa de um destino de visualização pré-configurado ( ViewB). Mas e se ViewBtambém precisar de um destino de exibição pré-configurado ViewC? Eu preciso criar ViewBjá de tal forma que ViewCjá é injetado em ViewBantes de eu injetar ViewBem ViewA. E assim por diante ... mas como os dados que naquele momento precisam ser transmitidos não estão disponíveis, toda a construção falha.

Outra idéia que tive foi usar o Environmentmecanismo de injeção como dependência para injetar destinos NavigationLink. Mas acho que isso deve ser considerado mais ou menos como um hack e não uma solução escalável para aplicativos grandes. Nós acabaríamos usando o Ambiente basicamente para tudo. Mas como o Ambiente também pode ser usado apenas dentro do View (não em coordenadores ou ViewModels separados), isso criaria novamente construções estranhas na minha opinião.

Assim como a lógica de negócios (por exemplo, ver código do modelo) e a visualização, também é necessário separar a navegação e a visualização (por exemplo, o padrão do coordenador). UIKitÉ possível porque acessamos UIViewControllere UINavigationControllerpor trás da visualização. UIKit'sO MVC já teve o problema de juntar tantos conceitos que se tornou o nome divertido "Massive-View-Controller" em vez de "Model-View-Controller". Agora, um problema semelhante continua, SwiftUImas ainda pior na minha opinião. A navegação e as visualizações são fortemente acopladas e não podem ser dissociadas. Portanto, não é possível fazer visualizações reutilizáveis ​​se elas contiverem navegação. Foi possível resolver isso, UIKitmas agora não consigo ver uma solução sã noSwiftUI. Infelizmente, a Apple não nos deu uma explicação sobre como resolver problemas de arquitetura como esse. Temos apenas alguns aplicativos de amostra pequenos.

Eu adoraria provar que estou errado. Mostre-me um padrão de design de aplicativo limpo que resolve isso para aplicativos prontos para grandes produções.

Desde já, obrigado.


Atualização: essa recompensa terminará em alguns minutos e, infelizmente, ainda ninguém foi capaz de fornecer um exemplo de trabalho. Mas vou iniciar uma nova recompensa para resolver esse problema se não encontrar outra solução e vinculá-la aqui. Obrigado a todos pela excelente contribuição!


11
Acordado! Eu criei uma solicitação para isso no “Feedback Assistant” há muitos meses, ainda sem resposta: gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245
Sajjon

@Sajjon Thanks! Pretendo escrever a Apple também, vamos ver se recebo uma resposta.
Darko

11
A escreveu uma carta à Apple sobre isso. Vamos ver se temos uma nova revisão.
Darko

11
Agradável! Seria o melhor presente durante a WWDC de longe!
Sajjon 23/04

Respostas:


10

O fechamento é tudo que você precisa!

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

Eu escrevi um post sobre a substituição do padrão delegado no SwiftUI por fechamentos. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/


O fechamento é uma boa ideia, obrigado! Mas como isso seria em uma hierarquia de visão profunda? Imagine que eu tenho um NavigationView que vai 10 níveis mais fundo, detalhe, detalhe, detalhe, etc ...
Darko

Gostaria de convidá-lo para mostrar um código de exemplo simples de apenas três níveis de profundidade.
Darko

7

Minha idéia seria praticamente uma combinação de Coordinatore Delegatepadrão. Primeiro, crie uma Coordinatorclasse:


struct Coordinator {
    let window: UIWindow

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

Adapte o SceneDelegatepara usar o 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, temos o seguinte:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

Podemos definir o ContenViewDelegateprotocolo assim:

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

Onde Itemé apenas uma estrutura que é identificável, poderia ser qualquer outra coisa (por exemplo, id de algum elemento como em um TableViewno UIKit)

O próximo passo é adotar esse protocolo Coordinatore simplesmente passar a visualização que você deseja apresentar:

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

Até agora, isso funcionou bem nos meus aplicativos. Espero que ajude.


Obrigado pelo código de exemplo. Gostaria de convidá-lo a mudar Text("Returned Destination1")para algo como MyCustomView(item: ItemType, destinationView: View). Portanto, isso MyCustomViewtambém precisa de alguns dados e destino injetados. Como você resolveria isso?
Darko

Você se depara com o problema de aninhamento que descrevo em minha postagem. Por favor corrija-me se eu estiver errado. Basicamente, essa abordagem funciona se você tiver uma visão reutilizável e essa visão reutilizável não contiver outra visão reutilizável com o NavigationLink. Que é um caso de uso bastante simples, mas não é dimensionável para aplicativos grandes. (onde quase todas as visualizações são reutilizáveis)
Darko

Isso depende muito de como você gerencia as dependências do aplicativo e o fluxo delas. Se você está tendo dependências em um único local, como deve IMO (também conhecido como raiz da composição), não deve encontrar esse problema.
Nikola Matijevic

O que funciona para mim é definir todas as suas dependências para uma visualização como um protocolo. Adicione conformidade ao protocolo na raiz da composição. Passe dependências para o coordenador. Injete-os pelo coordenador. Em teoria, você deve terminar com mais de três parâmetros, se feito corretamente, nunca mais que dependenciese destination.
Nikola Matijevic

11
Eu adoraria ver um exemplo concreto. Como eu já mencionei, vamos começar em Text("Returned Destination1"). E se isso precisar ser um MyCustomView(item: ItemType, destinationView: View). O que você vai injetar lá? Entendo a injeção de dependência, o acoplamento flexível através de protocolos e as dependências compartilhadas com os coordenadores. Tudo isso não é o problema - é o aninhamento necessário. Obrigado.
Darko

2

Algo que me ocorre é que quando você diz:

Mas e se o ViewB também precisar de um ViewC de destino de exibição pré-configurado? Eu precisaria criar o ViewB já de forma que o ViewC já seja injetado no ViewB antes de injetar o ViewB no ViewA. E assim por diante .... mas como os dados que naquele momento precisam ser transmitidos não estão disponíveis, toda a construção falha.

não é bem verdade. Em vez de fornecer visualizações, você pode projetar seus componentes reutilizáveis ​​para fornecer tampas que fornecem visualizações sob demanda.

Dessa forma, o fechamento que produz o ViewB on demand pode fornecê-lo com um fechamento que produz o ViewC sob demanda, mas a construção real das visualizações pode ocorrer no momento em que as informações contextuais necessárias estão disponíveis.


Mas como a criação dessa "árvore de fechamento" difere das visões reais? O problema de fornecimento do item seria resolvido, mas não o aninhamento necessário. Eu crio um fechamento que cria uma visão - ok. Mas nesse fechamento eu já precisaria fornecer a criação do próximo fechamento. E no último o próximo. Etc ... mas talvez eu te entenda mal. Algum exemplo de código ajudaria. Obrigado.
Darko

2

Aqui está um exemplo divertido de detalhar infinitamente e alterar seus dados para a próxima exibição de detalhes programaticamente

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

-> alguma visão obriga a sempre retornar apenas um tipo de visão.
Darko

A injeção de dependência com o EnvironmentObject resolve uma parte do problema. Mas: algo crucial e importante em uma estrutura de interface do usuário deve ser tão complexo ...?
Darko

Quero dizer - se a injeção de dependência é a única solução para isso, então eu aceitaria com relutância. Mas isso realmente cheira ...
Darko

11
Não vejo por que você não pode usar isso com o seu exemplo de estrutura. Se você está falando de uma estrutura que vende uma visão desconhecida, eu imaginaria que ela poderia retornar alguma visão. Também não ficaria surpreso se um AnyView dentro de um NavigationLink não fosse tão grande de um hit anterior, pois a visualização pai é completamente separada do layout real do filho. Não sou especialista, porém, teria que ser testado. Em vez de pedir a todos um código de amostra onde eles não possam entender completamente seus requisitos, por que você não escreve um exemplo de UIKit e pede traduções?
jasongregori 25/04

11
Esse design é basicamente como o aplicativo (UIKit) em que trabalho funciona. Modelos são gerados com links para outros modelos. Um sistema central determina qual vc deve ser carregado para esse modelo e, em seguida, o vc principal o empurra para a pilha.
jasongregori 25/04

2

Estou escrevendo uma série de posts sobre como criar uma abordagem MVP + Coordinators no SwiftUI, o que pode ser útil:

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

O projeto completo está disponível no Github: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

Estou tentando fazer isso como se fosse um grande aplicativo em termos de escalabilidade. Acho que resolvi o problema de navegação, mas ainda preciso ver como fazer links diretos, e é nisso que estou trabalhando atualmente. Espero que ajude.


Uau, isso é ótimo, obrigado! Você fez um bom trabalho ao implementar coordenadores no SwiftUI. A ideia de fazer NavigationViewa visualização raiz é fantástica. Esta é de longe a implementação mais avançada de coordenadores SwiftUI que eu vi de longe.
Darko

Gostaria de conceder a você a recompensa apenas porque a sua solução de coordenador é realmente ótima. O único problema que tenho - ele realmente não trata do problema que eu descrevo. Ele se dissocia, NavigationLinkmas o faz introduzindo uma nova dependência acoplada. O MasterViewexemplo não depende NavigationButton. Imagine colocar MasterViewem um pacote Swift - ele não seria mais compilado porque o tipo NavigationButtoné desconhecido. Também não vejo como o problema de reutilizável aninhado Viewsseria resolvido por ele?
Darko

Eu ficaria feliz em estar errado, e se estiver, por favor, explique-me. Mesmo que a recompensa se esgote em alguns minutos, espero poder premiar de alguma forma os pontos. (nunca fiz uma recompensa antes, mas acho que posso criar uma pergunta de acompanhamento com uma nova?)
Darko

1

Esta é uma resposta completamente absurda, então provavelmente será um absurdo, mas eu ficaria tentado a usar uma abordagem híbrida.

Use o ambiente para passar por um único objeto coordenador - vamos chamá-lo de NavigationCoordinator.

Dê às suas visualizações reutilizáveis ​​algum tipo de identificador que é definido dinamicamente. Esse identificador fornece informações semânticas correspondentes ao caso de uso real do aplicativo cliente e à hierarquia de navegação.

Faça com que as visualizações reutilizáveis ​​consultem o NavigationCoordinator quanto à visualização de destino, passando seu identificador e o identificador do tipo de visualização para o qual estão navegando.

Isso deixa o NavigationCoordinator como um único ponto de injeção e é um objeto sem visualização que pode ser acessado fora da hierarquia da visualização.

Durante a instalação, você pode registrar as classes de exibição corretas para retornar, usando algum tipo de correspondência com os identificadores que são transmitidos no tempo de execução. Algo tão simples quanto combinar com o identificador de destino pode funcionar em alguns casos. Ou combinando com um par de identificadores de host e destino.

Em casos mais complexos, você pode escrever um controlador personalizado que leve em consideração outras informações específicas do aplicativo.

Como é injetado pelo ambiente, qualquer visualização pode substituir o NavigationCoordinator padrão a qualquer momento e fornecer um diferente para suas subvisões.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.