
Jetpack Compose Part II: async data, tabs, scroller, fab, state, …
No post anterior, fiz uma breve introdução sobre o Jetpack Compose, como criar as primeiras composable functions e como manter estados nos componentes. E conforme mencionei, à medida que fosse estudando novas coisas, iria postando aqui.
A ideia desse post é apresentar mais alguns conceitos do Compose, mostrar a utilização de mais alguns componentes e carregar dados vindos da web. Para isso criaremos a aplicação ilustrada na imagem.

É uma simples listagem de livros. Na primeira aba, teremos os livros que serão obtidos do servidor. E a segunda exibirá os livros marcados como favoritos (apenas em memória).
Configurando o projeto
Crie um novo projeto no Android Studio 4.0, selecionando a opção "Empty Compose Activity".
Adicione as seguintes dependências no build.gradle da aplicação:
dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-rc01'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.squareup.okhttp3:okhttp:4.2.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2-1.3.60-eap-76'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2-1.3.60-eap-76'
}A biblioteca de Lifecycle será utilizada para lançar uma coroutine a partir da Activity por meio do atributo lifecycleScope. O Picasso será utilizada no carregamento de imagens. As dependências do OkHttp e do Gson são necessárias para realizar respectivamente a comunicação com o servidor e fazer o parser do JSON. E por fim, as bibliotecas de Coroutines para podemos realizar as chamadas assíncronas.
Como o foco deste post é o Compose, e não como ler dados de forma assíncrona, copie as classes BookHttp, Book, Category e Publisher a partir desse link para o pacote http dentro do seu projeto (faça o ajuste necessário no nome do pacote). A primeira classe estabelecerá a comunicação com o servidor para obter a lista de livros, enquanto as demais são data classes simples.
AlertDialog
Quando usuário clicar em um livro da listagem de favoritos, uma mensagem será exibida para confirmar a exclusão do livro da lista de favoritos. A composable function que exibe o AlertDialog é listada a seguir:
@Composable
fun DeleteFavBookDialog(
resources: Resources,
book: Book,
onConfirm: (Book) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onCloseRequest = onDismiss,
title = {
Text(
text = resources.getString(
R.string.msg_fav_delete_title),
style = +themeTextStyle { h6 }
)
},
text = {
Text(
text = resources.getString(
R.string.msg_fav_delete_message, book.title),
style = +themeTextStyle { body2 }
)
},
confirmButton = {
Button(
text = resources.getString(
R.string.msg_fav_delete_confirm),
style = ContainedButtonStyle(),
onClick = {
onConfirm(book)
onDismiss()
}
)
},
dismissButton = {
Button(
text = resources.getString(
R.string.msg_fav_delete_cancel),
style = TextButtonStyle(),
onClick = onDismiss
)
}
)A função recebe um objeto do Resource que será utilizado para acessar as strings definidas no res/values/strings.xml.
<string name="msg_fav_delete_title">Delete</string>
<string name="msg_fav_delete_message">Do you want to remove \'%s\' from the favorites?</string>
<string name="msg_fav_delete_confirm">Yep!</string>
<string name="msg_fav_delete_cancel">Nope!</string>O parâmetro book indica o livro que será excluído da lista de favoritos caso o usuário confirme. Os dois últimos parâmetros são lambdas que serão executados ao clicar no botão de confirmação e ao fechar o dialog.
Para criar o AlertDialog, é informado um título, um texto, um botão de confirmação e outro botão para fechar o dialog. Todos são composable functions, ou seja, qualquer componente pode ser informado, fazendo com que o dialog seja facilmente customizado. Para o título e o texto, estamos usando o estilo de texto definido no tema da aplicação por meio do +themeTextStyle. Já para o botão, utilizamos os estilos ContainerButtonStyle para confirmação e TextButtonStyle para o cancelamento do dialog.

Book Card
O BookItem exibirá as seguintes informações do livro: imagem da capa, título, autor, número de páginas e ano de publicação. Entretanto, no momento da publicação desse post, o componente Image do Compose não suporta o carregamento de imagens via URL. Para realizar esse trabalho, estou utilizando a classe Image criada pelo Leland Richardson (do time do Compose no Google) que utiliza o Picasso para fazer o request e cache da imagem. Espero que em breve tenhamos isso incorporado na API.
A seguir temos a composable function do card de livro.
@Composable
fun BookItem(
resources: Resources,
book: Book,
action: (Book) -> Unit
) {
Container(
modifier = Spacing(top = 16.dp, left = 16.dp, right = 16.dp)
) {
Card(shape = RoundedCornerShape(4.dp)) {
Ripple(bounded = true) {
Clickable(onClick = {
action(book)
}) {
BookItemContent(resources, book)
}
}
}
}
}Esse componente recebe como parâmetro um objeto Book e um lambda que será invocado quando o item for pressionado.
O objeto Container foi utilizado para definir umas margens para o card. dentro dele, temos um Card com a borda arredondada de 4dp. O componente Ripple se encarregará de fazer aquele efeito de onda do material design. Para fazer o item ser "clicável", foi utilizado o Clickable, que invoca o lambda passado como parâmetro.
O componente BookItemContent é listado a seguir:
@Composable
fun BookItemContent(
resources: Resources,
book: Book
) {
Row(mainAxisSize = LayoutSize.Expand) {
Image(url = book.coverUrl, width = 96.dp, height = 144.dp)
Column(
modifier = Spacing(16.dp),
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
) {
Text(
text = book.title,
style = (+themeTextStyle { h6 })
.withOpacity(0.87f)
)
Text(
text = book.author,
style = (+themeTextStyle { body2 })
.withOpacity(0.87f)
)
Text(
text = resources.getString(
R.string.book_info_year_pages,
book.year,
book.pages
),
style = (+themeTextStyle { body2 })
.withOpacity(0.6f)
)
}
}
}Perceba que foi utilizado o componente customizado Image passando a URL e o tamanho desejado da imagem.
Foram feitas mudanças na opacidade da cor dos textos utilizando a função withOpacity.
A string que tem o placeholder do ano e das páginas deve ser adicionada no res/values/strings.xml.
<string name="book_info_year_pages">Year: %1$d - Pages: %2$d</string>
Lista de livros
O Compose ainda não possui um componente como a RecyclerView tradicional do Android, por isso, por enquanto será utilizado o componente VerticalScroller como mostrado a seguir.
@Composable
fun BooksList(
resources: Resources,
books: Collection<Book>?,
action: (Book) -> Unit
) {
if (books == null || books.isEmpty()) {
Container(expanded = true, alignment = Alignment.Center) {
Text(
resources.getString(R.string.msg_book_list_empty),
style = +themeTextStyle { h6 }
)
}
return
}
Container(expanded = true, alignment = Alignment.TopLeft) {
VerticalScroller {
Column {
books.forEach { book ->
BookItem(resources, book, action)
}
}
}
}
}Um detalhe interessante nesse componente é que, se a lista estiver vazia ou for nula, é exibido um texto (perceba que há um return nesse caso). A string utilizada nesse caso é exibida a seguir:
<string name="msg_book_list_empty">The book\'s list is empty!</string>Note que para a "empty view" o Container ocupa toda a área disponível (expanded = true) e exibe a mensagem centralizada na tela (alignment = Center). Já o para o VerticalScroller o alinhamento é definido para o topo da tela para os itens sejam exibidos de cima para baixo.
A parte mais curiosa é que utilizamos um simples forEach para listar os livros, instanciando um BookItem para cada item da lista. Provavelmente o componente de listagem (ainda não implementado na API) deverá tratar as questões de otimização.

Abas
Como mencionado no início do post, a aplicação exibirá duas abas: uma para os livros que são carregados da web e outra para que são marcados como favoritos. O componente que faz esse trabalho é listado a seguir:
@Composable
fun Tabs(
resources: Resources,
selectedTab: Int,
onSelected: (Int) -> Unit
) {
TabRow(
items = listOf(
resources.getString(R.string.tab_books),
resources.getString(R.string.tab_favorites)
),
selectedIndex = selectedTab,
tab = { index, string ->
Tab(
text = string,
selected = selectedTab == index,
onSelected = {
onSelected(index)
}
)
}
)
}O componente TabRow é o responsável por exibir as abas. Passamos como parâmetro uma lista com os títulos de cada aba:
<string name="tab_books">Books</string>
<string name="tab_favorites">Favorites</string>O segundo parâmetro é o índice da aba selecionada. E por fim, temos o parâmetro tab que é o responsável por chamar uma composable function para montar cada Tab. Perceba que ao selecionar a aba, o lamba onSelected passado como parâmetro é invocado para atualizar a aba selecionada.

Juntando tudo na tela de listagem de livros
A primeira coisa que definiremos é o modelo que representará o estado da tela. Isso será feito por meio da classe BookScreenState.
@Model
data class BookScreenState(
var selectedTab: Int,
var isDeleteDialogOpen: Boolean,
var bookToDelete: Book?,
var booksFavorites: MutableSet<Book> = mutableSetOf()
)Temos aqui, respectivamente: o índice da aba selecionada; um booleano indicando se o dialog de confirmação de exclusão está aberto; o livro que será removido dos favoritos; a lista de livros favoritos.
Finalmente, a BooksScreen deve ficar como a seguir:
@Composable
fun BooksScreen(
result: ListBookResult
) {
val context = +ambient(ContextAmbient)
val resources = context.resources
if (result.loading) {
Loading(resources)
return
}
val screenState by +state {
BookScreenState(0, false, null)
}
if (screenState.isDeleteDialogOpen) {
screenState.bookToDelete?.let { book ->
DeleteFavBookDialog(
resources,
book,
onConfirm = { bookToDelete ->
screenState.booksFavorites.remove(bookToDelete)
},
onDismiss = {
screenState.run {
bookToDelete = null
isDeleteDialogOpen = false
}
}
)
}
}
BooksScreenContent(context, resources, result, screenState)
}Vamos analisar cada parte desse componente.
A BooksScreen recebe como parâmetro um objeto ListBookResult que será passado pela MainActivity.
@Model
data class ListBookResult(
var loading: Boolean,
var books: List<Book>
)Basicamente esse objeto informa ao componente se os dados estão sendo carregados e a lista dos livros carregados da web. Se os dados ainda estiverem sendo carregados, um texto é exibido informando esse estado.
@Composable
fun Loading(resources: Resources) {
Container(alignment = Alignment.Center, expanded = true) {
Text(
resources.getString(R.string.msg_loading),
style = +themeTextStyle { h6 }
)
}
}A string utilizada é listada a seguir:
<string name="msg_loading">Loading books…</string>Vimos que vários componentes utilizam o parâmetro resources. Esse objeto é obtido a partir de um Context e esse contexto é obtido por meio da chamada +ambient(ContextAmbient).
A função +state pode ser utilizada para introduzir um estado no componente. Nesse exemplo, foi instanciado um objeto BookScreenState.
Para exibir o AlertDialog verificamos o estado isDeleteDialogOpen ebookToDelete. Perceba que as ações de confirmação e de fechamento do dialog simplesmente alteram o estado do componente, fazendo com que ele seja redesenhado.
O conteúdo da tela é desenhado por meio da composable function BooksScreenContent listada a seguir.
@Composable
fun BooksScreenContent(
context: Context,
resources: Resources,
result: ListBookResult,
screenState: BookScreenState
) {
FlexColumn {
DrawShape(shape = RectangleShape, color = Color(0xfafafa))
inflexible {
Tabs(resources = resources,
selectedTab = screenState.selectedTab,
onSelected = { index ->
screenState.selectedTab = index
})
}
expanded(1f) {
when (screenState.selectedTab) {
0 -> BooksList(resources, result.books) { book ->
screenState.booksFavorites.add(book)
Toast.makeText(
context,
resources.getString(
R.string.msg_added_favorites,
book.title
),
Toast.LENGTH_SHORT
).show()
}
1 -> BooksList(
resources,
screenState.booksFavorites
) { book ->
screenState.run {
bookToDelete = book
isDeleteDialogOpen = true
}
}
}
}
}
BottomRightFab()
}Esse componente possui um FlexColumn que permite determinar se os filhos irão expandir ou ficar de um tamanho fixo. Dentro desse componente, um DrawShape foi invocado para desenhar um background quadrado (RectangleShape) sólido da cor especificada.
As abas tem uma altura fixa, por isso é declarada como inflexible. Perceba que o lambda que é passado para as abas simplesmente altera o estado da tela com o índice da aba selecionada, fazendo com que a tela seja redesenhada.
O conteúdo das abas é expandido (expanded) para ocupar o restante da tela. Para exibir a aba correta, mais um vez o estado do componente é referenciado. Se a aba selecionada for a primeira, criamos um BookList usando a lista de livros que será passada pela activity, e a ação a ser realizada ao clicar no item da lista será a de adicionar o livro à lista de favoritos e exibir um Toast. O texto da mensagem é listado a seguir.
<string name="msg_added_favorites">\'%s\' added to favorites!</string>Mas se a aba selecionada for a de favoritos, a ação de clicar no item da lista alterará o estado da tela para exibir o AlertDialog e atribuir o livro que poderá ser excluído caso o usuário confirme a ação no dialog.
FloatingActionButton
Na verdade o FloatingActionButton não faz nada nesse exemplo, mas eu decidi colocá-lo aí porque deu trabalho para deixar ele apresentável :)
@Composable
fun BottomRightFab() {
Container(
expanded = true,
alignment = Alignment.BottomRight,
padding = EdgeInsets(all = 16.dp)
) {
FloatingActionButton(
color = Color.Red,
onClick = {
// TODO
}
) {
Container(width = 24.dp, height = 24.dp) {
DrawVector(
+vectorResource(R.drawable.ic_baseline_add)
)
}
}
}
}A dificuldade que eu tive foi colocar o botão alinhado na parte inferior direita. Isso foi possível utilizando o Container. Por padrão, o FAB aceita um Image como parâmetro para o ícone, mas esse ícone deve ser um bitmap. Para utilizar um vector drawable como ícone, foi preciso utilizar DrawVector passando como parâmetro o retorno da função +vectorResource. E por fim, utilizar o Container para alinhar o ícone dentro do FAB.
Utilizando o componente na MainActivity
Finalmente será apresentado como invocar a tela a partir da MainActivity.
class MainActivity : AppCompatActivity() {
private val booksResult: ListBookResult =
ListBookResult(false, emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
BooksScreen(booksResult)
}
}
lifecycleScope.launch {
booksResult.loading = true
val books = withContext(Dispatchers.IO) {
BookHttp.loadBooksGson()
}
booksResult.books = books ?: emptyList()
booksResult.loading = false
}
}
}Foi instanciado um BooksScreen passando como parâmetro o atributo booksResult que inicialmente indica que os dados não estão sendo carregados e que a lista é vazia.
Entretanto, logo em seguida uma coroutine é lançada para carregar os livros armazenados no servidor. Note que antes realizar a conexão, o estado loading é alterado para true. Quando a lista de livros é obtida, o estado do componente é modificado novamente passando a lista de livros e informando que o carregamento foi concluído.
Por fim, não se esqueça de fazer os seguintes ajustes no AndroidManifest.xml.
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<application
...
android:usesCleartextTraffic="true">
...
</application>
</manifest>Como os dados vêm da internet, precisamos adicionar essa permissão. E como o acesso não está sendo feito via HTTPS (my bad), temos que declarar usesCleartextTraffic como true.
Resultado
A figura a seguir mostra a aplicação em funcionamento.

Como o Jetpack Compose ainda está em estágios iniciais, muitas coisas ainda faltam para termos uma UI bem consistente. Apenas nesse exemplo posso citar alguns desafios que devem ser implementados em futuras versões do Compose:
- Gesto para mudança de abas;
- Carregamento de imagens a partir de uma URL;
- FloatingActionButton suportar vector drawables;
- Componente de listagem com reciclagem de views.
Creio que em próximas versões do Compose teremos melhorias, e como eu mencionei no artigo anterior, é bom ir se acostumando com a nova maneira de trabalhar com UIs utilizando o Compose ;)
4br4ç05
