Jetpack Compose: o framework de UI do Android para os próximos 10 anos.
No Google I/O de 2019 o Google anunciou o Jetpack Compose, um toolkit desenvolvido com o intuito de simplificar a criação de interfaces gráficas no Android. Meses depois, no Android Dev Summit 2019, o Google o apresentou as novidades do Jetpack Compose e sua integração com o Android Studio 4.0.
O Compose utiliza Kotlin para implementar a abordagem de UI declarativa de composição de componentes, algo muito parecido com o React Native, Flutter e Swift UI, onde a hierarquia de componentes é redesenhada automaticamente quando o seu estado interno é modificado.
Em seu lançamento, foi bastante enfatizado que o Compose tende a ser a solução de UI do Android para os próximos anos.
Nesse post vou apresentar como dar os primeiros passos com o Jetpack Compose utilizando Android Studio, que pode ser baixado clicando aqui.
Criando o Projeto de exemplo
Inicie um novo projeto no Android Studio 4.2 e será exibida a tela a seguir:
Selecione a opção "Empty Compose Activity", clique em "Next" e em seguida preencha os demais campos para a criação do projeto.
Uma vez criado o projeto, no build.gradle
do módulo app, temos a seção buildFeatures
onde o compose é habilitado. E no bloco composeOptions
, podemos observar algumas configurações adicionais para o compilador do Kotlin feitas exclusivamente para o Compose, como a versão do compilador do Kotlin que inclui as mudanças necessárias para o uso do Compose. Por fim, na seção dependencies
temos as dependências das bibliotecas do Compose.
android {
...
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.4.32'
}
}
dependencies {
...
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
}
Abra a MainActivity
e o código deve estar como a seguir:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Demo1Theme {
Surface(color = MaterialTheme.colors.background) {
Greeting("Android")
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
Demo1Theme {
Greeting("Android")
}
}
Como é possível perceber, no método setContent
está sendo passado um lambda onde a composição da UI inicia com um <SeuApp>Theme
que trará as definições de estilo da UI. Dentro do tema, temos a declaração do primeiro componente customizado chamado Greeting
onde é passada a mensagem que será exibida na tela.
Perceba que Greeting
nada mais é do que uma função com a anotação @Composable
onde temos a declaração do componente Text
(que é similar ao bom e velho TextView
) que faz parte da biblioteca core do Compose e exibe um texto na tela.
Mas o interessante desse exemplo é que podemos ter a pré-visualização do componente dentro do próprio Android Studio. Isso é feito utilizando a anotação @Preview
na função DefaultPreview
. Para isso, basta compilar a aplicação (menu Build > Make Project) e o resultado será como a seguir:
Sempre que for feita uma mudança no código, é preciso pressionar o botão "Build & Refresh" como indicado na imagem para que o preview seja atualizado.
Rows and Columns
A maneira mais simples de adicionar componentes à tela é utilizando os componentes Row
e Column
. Ao adicionar componentes dentro de um Column
eles ficam um abaixo do outro e no Row
eles ficam um ao lado do outro.
@Composable
fun RowsAndColumns() {
Column {
Text("This is the first text")
Text("This is the second text")
Text("This is the third text")
Row {
Text("Col 1")
Text("Col 2")
Text("Col 3")
}
}
}
Substitua o componente Greeting
por RowsAndColumns
na MainActivity
e na função DefaultPreview
e o resultado ficará como a seguir:
Diversos componentes do Compose possuem o parâmetro modifier
que permite aplicar diversas mudanças de forma sucessiva e encadeada no componente, tais como: espaçamento, background, bordas, largura/altura, evento de clique, etc.
Vejamos o código a seguir:
@Composable
fun RowsAndColumns() {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("This is the first text")
Text("This is the second text")
Text("This is the third text")
Row(
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text("Col 1")
Text("Col 2")
Text("Col 3")
}
}
}
O resultado ficará como a seguir:
Perceba que no componente Column
, foi utilizado o parâmetro modifier
para definir um espaçamento utilizando a função padding()
(dp
é uma extension function da classe Int
) e a função fillMaxWidth()
seria um equivalente ao velho "match_parent" do XML. Os itens são centralizados utilizando o parâmetro horizontalGravity
.
Já na Row
foi adicionado um espaçamento apenas no topo (padding
com parâmetro top
). Em seguida, para que os items fiquem distribuídos igualitariamente na horizontal, foi utilizado o valor Arrangement.SpaceEvenly
.
State
Na abordagem atual de desenvolvimento de interfaces gráficas, o componente possui um estado interno, e sempre que os dados da aplicação são alterados, o componente de UI deve ser atualizado utilizando os dados da aplicação. O mesmo deve acontecer quando o componente visual muda seu estado, ele deve atualizar o modelo de dados da aplicação. Isso é uma fonte comum de bugs na aplicações, pois os dados e a UI podem ficar inconsistentes.
Umas das motivações do Compose é utilizar um "single source of truth", que basicamente significa que a fonte dos dados será única. Desta forma, os componentes de UI representarão seus estados utilizando os dados da própria aplicação, evitando assim divergências entre dados e a UI.
A maneira mais simples de adicionar um estado é utilizando a função mutableStateOf
. A função remember
permite lembrar de um determinado valor após o componente ser atualizado.
@Composable
fun DemoState() {
var isChecked by remember { mutableStateOf(false) }
Checkbox(
checked = isChecked,
onCheckedChange = { isChecked = it}
)
}
A variável isChecked
está sendo adicionada ao estado da tela. Perceba que o componente Checkbox
confia nesse valor. Por isso, no onCheckedChange
esse valor é atualizado, o que faz com que o componente seja redesenhado.
Mas uma tela normalmente possui um conjunto de estados e para representá-lo, temos duas opções:
// Opção 1
class Score(
homeTeam: String,
homeScore: Int,
visitorTeam: String,
visitorScore: Int
) {
var homeTeam by mutableStateOf(homeTeam)
var homeScore by mutableStateOf(homeScore)
var visitorTeam by mutableStateOf(visitorTeam)
var visitorScore by mutableStateOf(visitorScore)
}// Opção 2
data class Score(
val homeTeam: String,
var homeScore: Int,
val visitorTeam: String,
var visitorScore: Int
)
A opção 1, apesar de ser mais verbosa e não permitir usar uma data class
, possui a vantagem de permitir a atualização da UI simplesmente alterando a propriedade do objeto. Já a opção 2 é mais simples, pois usa uma data class
normal, sem nenhuma mudança. Mas um ponto negativo é que para atualizar o estado é preciso fazer uma cópia do objeto original, alterar a(s) propriedade(s) desejada(s) e reatribuir o objeto. Outro problema é que se o objeto for passado para um nível muito profundo da hierarquia, o componente poderá não ser atualizado corretamente. Sendo assim, utilizarei a opção 1.
Criando uma tela com estado
Nesse exemplo será implementada uma tela que exibirá o placar de uma partida de futebol (mas poderia se vôlei, basquete, …), onde serão exibidos os nomes dos times e será possível incrementar e decrementar o placar da partida. Essa tela será representada pela composable function ScoreView
listada a seguir:
@Composable
fun ScoreView(score: Score) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.padding(bottom = 16.dp)
) {
TeamScore(
team = score.homeTeam,
score = score.homeScore,
onUpdate = { newScore ->
score.homeScore = newScore
}
)
Text(
text = "x",
modifier = Modifier
.padding(start = 16.dp, end = 16.dp),
style = TextStyle(
fontSize = 24.sp,
color = Color.Red
)
)
TeamScore(
team = score.visitorTeam,
score = score.visitorScore,
onUpdate = { newScore ->
score.visitorScore = newScore
}
)
}
OutlinedButton(
onClick = {
score.homeScore = 0
score.visitorScore = 0
}
) {
Text("Reset")
}
}
}
Nesta função, o objeto Score
é passado por parâmetro. Quando esse objeto for modificado, o componente será automaticamente atualizado.
O componente inicia com um Column
que ocupará todo o tamanho da tela. Isso é feito utilizando a função fillMaxSize
, que é o equivalente a fillMaxWidth
e fillMaxHeight
juntos. Os componentes filhos ficarão centralizados verticalmente (verticalArrangement
) e horizontalmente (horizontalAlignment
).
Em seguida, uma Row
é utilizada para exibir o placar de cada time. O placar é representado pelo componente TeamScore
que veremos logo a seguir.
Entre o placar de cada time é exibido um Text
com um "x". Perceba que foi configurado o estilo do texto por meio do parâmetro style
. No objeto TextStyle
, foi informado o tamanho do texto e a cor (fontSize
e color
).
Por fim, temos um OutlinedButton
que exibirá o botão com uma borda e que reiniciará o placar do jogo.
Vejamos agora o componente TeamScore
:
@Composable
fun TeamScore(
team: String,
score: Int,
onUpdate: (Int) -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = team,
style = TextStyle(
fontSize = 24.sp
),
modifier = Modifier.padding(bottom = 16.dp)
)
Button(
onClick = {
onUpdate(score + 1)
}
) { Text("+") }
Text(
text = score.toString(),
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(16.dp)
)
Button(
onClick = {
onUpdate(max(score - 1, 0))
}
) { Text("-") }
}
}
Este componente recebe como parâmetro: o nome do time, o placar atual do time, um lambda que será executado para informar o novo valor do placar.
A UI é composta de um Column
que exibe os componentes de forma centralizada. Dentro dele, temos: um Text
que exibe o nome do time; dois componentes Button
que possuem um estilo diferente do botão de “Reset” e que chamam a função onUpdate
(um incrementando, outro decrementando).
Perceba que o Text
que exibe o score está usando o style
para definir o tamanho do texto por meio da sintaxe MaterialTheme.typography.h4
onde é o utilizado o tema corrente da aplicação (MaterialDesign
) para obter o estilo de texto h4
(header 4, da mesma forma do HTML).
Finalmente, utilize o componente ScoreView
na MainActivity
.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Demo1Theme {
ScoreView(
Score("Corinthians", 0, "São Paulo", 0)
)
}
}
}
}
O resultado ficará como a seguir:
Conclusão
O Compose traz para a plataforma Android os mesmos conceitos de frameworks de UI modernos e amplamente utilizados no mercado como React, Flutter, SwiftUI, etc. Apesar de ainda se encontrar em um estágio inicial, o Google parece estar apostando bastante Compose, e a ideia é que ele seja a nova forma padrão de construção de interfaces gráficas no Android.
A previsão de lançamento do Jetpack Compose é 2021, mas cabe a nós desenvolvedores ir se adaptando a essa nova forma de escrever UI no Android.
No momento, o Compose não está pronto para produção, e vem tendo constantes mudanças de API (esse artigo, inclusive já foi atualizado duas vezes), mas nós como desenvolvedores já devemos começar a estudar, nos preparar e se acostumar com essa API de modo a estarmos prontos para quando sua versão estável estiver disponível.
Espero que tenham gostado do post. Aproveite para ler a parte II ;)
4br4ç05
nglauber