Views assíncronas genéricas

No artigo anterior nós vimos uma maneira de representar dados assíncronos por meio de uma enum com dois recursos interessantes: ela é genérica, de modo que pode se adaptar a qualquer tipo de dados; e ela tem um valor associado quando a informação está disponível. Vou copiar abaixo a definição dessa enum por conveniência:

/// Armazena o estado de um dado assíncrono
public enum AsyncData<Data> {
    /// O dado ainda não está disponível
    case loading
    /// Houve uma falha na requisição e o dado nunca estará disponível
    case failed
    /// A informação está disponível
    case loaded(Data)
}

Nós também vimos um exemplo de uso para exibir o texto “Nome: …” quando essa informação está disponível:

struct AsyncNameView: View {
    let name: AsyncData<String>


    var body: some View {
        switch name {
        case .loading:
            Text("Carregando…")
                .opacity(0.5)
        case .failed:
            Text("Erro na comunicação!")
                .color(.red)
        case .loaded(let name):
            Text("Nome: \(name)")
        }
    }
}

Só que nós temos alguns problemas nesse código aí do AsyncNameView. Quer dizer, funciona sem bugs; mas é um código que só serve para exibir nomes — afinal, o dado assíncrono é do tipo String (linha 2) e a renderização do dado disponível é um texto com prefixo "Nome: " (linha 14). Mas e quando for um endereço? Ou um dado que não é do tipo String — um inteiro ou uma imagem, por exemplo? Parece injusto ter que copiar várias vezes todo esse bloco de código acima só pra alterar as linhas 2 e 14, não é mesmo? Além disso, se no futuro decidirmos modificar essa interface (alterar a opacidade, traduzir as mensagens, descrever melhor o erro de comunicação…) a gente teria que fazer alterações em todas as versões das structs AsyncAlgumaCoisaView.

Neste post vamos ver como conseguimos generalizar essa view para que ela possa servir para qualquer tipo de dados, em especial para gerar qualquer tipo de view (não só um Text). Vamos lá?

Generalizando o tipo de dado da view

Uma abordagem possível é reescrever AsyncNameView de forma genérica, de modo que ela possa aceitar qualquer tipo de dado associado a AsyncData e também para que ela possa “renderizar” esse dado do jeito que a gente quiser.

Obviamente, para chegar aonde queremos o problema são as tais linhas 2 e 14, então vamos nos concentrar nelas. Vamos começar pela definição da propriedade, alterando seu nome para algo mais genérico — “asyncData” — e permitindo qualquer tipo de dado associado, ou seja, deixando de associar String:

struct AsyncDataView: View {
    let asyncData: AsyncData< ... opa, o que vem aqui?? ... >

Hmmm, aqui vem o primeiro problema: O que colocar no meio de <>? Tem que ser alguma coisa “genérica”, certo? Bom, nós já usamos algo bem parecido na definição da própria enum AsyncData: A gente usou o “tipo virtual” Data como um “placeholder” para algum tipo real que o programador define depois. A gente só precisa lembrar que a gente é obrigado a “declarar” esse tipo virtual. Então precisaremos alterar as duas primeiras linhas de código:

struct AsyncDataView<Data>: View {
    let asyncData: AsyncData<Data>


    var body: some View {
        switch name {
        case .loading:
            Text("Carregando…")
                .opacity(0.5)
        case .failed:
            Text("Erro na comunicação!")
                .color(.red)
        case .loaded(let name):
            // Text("Nome: \(name)") -- por enquanto deixou de funcionar
        }
    }
}

Agora vamos voltar a atenção para a linha 14, que é um pouquinho mais complicada. Vamos lembrar que a ideia é permitir “renderizar” o conteúdo do jeito que a gente quiser, ou seja, não podemos nos limitar a um Text. Por outro lado, a gente só consegue montar essa “renderização” depois que o dado estiver .loaded — em outras palavras, é impossível informar no construtor de AsyncDataView a aparência final da renderização, pois talvez o dado nem esteja disponível no momento da construção!

A solução para isso é: Quem criar uma instância de AsyncDataView terá que informar, por meio de um trecho de código-fonte, o jeito certo de renderizar o conteúdo. Na verdade é um recurso que a gente usa em vários lugares, que são as tais das “closures”: no próprio trecho de código acima a gente tem na linha 5 a declaração de uma propriedade, var body, seguida de um trecho de código-fonte (uma closure das linhas 5 a 16) que “ensina” a calcular o valor dessa propriedade.

Em resumo, a solução para uma renderização genérica é:

  1. Ao criar o AsyncDataView uma closure terá que ser informada para “ensinar” como se renderiza um dado .loaded;
  2. Essa closure terá que ser posteriormente “chamada” quando for a hora de renderizar.

Vamos a esses dois passos?

Armazenando a closure

Vamos começar com a parte em que essa closure é armazenada:

struct AsyncDataView<Data, LoadedView: View>: View {
    let asyncData: AsyncData<Data>
    let loadedViewGenerator: (Data) -> LoadedView

Pera, várias novidades em duas linhas de código!! Vamos analisar essas novidades:

  • Na linha 1 a gente passa a ter um segundo tipo genérico: além do já conhecido Data, agora a gente tem também LoadedView, que é o tipo resultante da renderização — pode ser Text como no exemplo inicial de “Nome: …”, pode ser Image, ou até coisas mais complexas como VStack ou ScrollView ou uma view que nós criamos em outro lugar. A única restrição é que, ao contrário de Data (que pode ser qualquer coisa), essa LoadedView precisa ser uma View — é por isso que aparece a restrição “: View” na linha 1.
  • Na linha 3 a gente cria uma propriedade que vai armazenar a closure. Essa propriedade não tem nada de especial: só parece esquisita porque o tipo dela é (Data) -> LoadedView. O que significa isso? Significa que “esta propriedade armazena uma closure que recebe um parâmetro do tipo Data e que precisa retornar uma LoadedView”.

Parece bem complicado, especialmente quando a gente for criar uma AsyncDataView. Na verdade o Swift consegue inferir (“adivinhar”) todos os tipos envolvidos durante a construção, e o Xcode nos ajuda bastante — a gente vê isso daqui a pouco.

Chamando a closure

Essa é a parte mais fácil de todas: Uma vez que a gente extrai o valor associado de .loaded, é só chamar a closure da mesma maneira que a gente chama uma função qualquer:

        case .loaded(let data):
            loadedViewGenerator(data)

Adotando AsyncDataView no lugar de AsyncNameView nas previews

A última coisa que falta para fecharmos é atualizar as #Previews que foram criadas no post anterior. O legal é que no Swift a definição da closure aparece daquela maneira clássica que já vemos em vários lugares — é um bloco de código que parece que “vem depois” do construtor (mas na verdade faz parte dele):

#Preview("Loaded") {
    AsyncDataView(asyncData: .loaded("Fulano de Tal")) { name in
        Text("Nome: \(name)")
    }
}

#Preview("Loading") {
    AsyncDataView(asyncData: .loading) { name in
        Text("Nome: \(name)")
    }
}

#Preview("Failed") {
    AsyncDataView(asyncData: .failed) { name in
        Text("Nome: \(name)")
    }
}

Prontinho, agora as previews estão funcionando! Agora, que tal seguir para a próxima parte desta sequência de posts para ver um pouquinho de integração de Views e ViewModels?

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *


Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.