Barra de progresso do Instagram com o Jetpack Compose
Posso dizer que a cada dia que passa fico mais empolgado com o Jetpack Compose. Estou tentando ao máximo me envolver e aprender mais sobre esse novo toolkit de UI do Android das mais diversas formas: acompanhando o canal sobre Compose no Slack do Kotlin; tentando responder algumas perguntas no Stack Overflow; fazendo apresentações; e principalmente aplicando em um projeto real na Nagarro.
Nesse projeto, recebi a tarefa de implementar uma barra de progresso no estilo dos stories do Instagram. Por coincidência, vi essa questão aqui no Stack Overflow que resolve o problema de exibir a barra de progresso e fazer a animação. Mas ainda restava implementar as ações de pausar a animação de progresso, ir para o próximo story e voltar para o story anterior.
Quando terminei a implementação, notei que ela envolve uns conceitos interessantes que resolvi compartilhar nesse post.
Desenhando e animando a barra de progresso
Digamos que essa foi a parte mais fácil. Vejamos como ficou a implementação inicial do componente.
@Composable
fun MyInstagramProgressIndicator(
modifier: Modifier = Modifier,
stepCount: Int,
stepDuration: Int,
unselectedColor: Color,
selectedColor: Color,
) {
var currentStepState by remember {
mutableStateOf(0)
}
val progress = remember {
Animatable(0f)
}
Row(modifier) {
for (i in 0 until stepCount) {
val stepProgress = when {
i == currentStepState -> progress.value
i > currentStepState -> 0f
else -> 1f
}
LinearProgressIndicator(
color = selectedColor,
backgroundColor = unselectedColor,
progress = stepProgress,
modifier = Modifier
.weight(1f)
.padding(2.dp)
)
}
}
LaunchedEffect(Unit) {
for (i in 0 until stepCount) {
progress.animateTo(
1f,
animationSpec = tween(
durationMillis = stepDuration,
easing = LinearEasing
)
)
if (currentStepState + 1 <= stepCount - 1) {
progress.snapTo(0f)
currentStepState += 1
}
}
}
}
Esse componente recebe os seguintes parâmetros:
modifier
: para ser aplicado naRow
que vai conter as barras de progresso;stepCount
: indica a quantidade de barras de progresso que serão exibidas;stepDuration
: determina o tempo de duração para preencher cada barra de progresso;unselectedColor
eselectedColor
: indicam respectivamente as cores de background e de preenchimento do progresso.
Essa função inicia com a definição de dois estados: currentStepState
indica qual a barra de progresso que deve ser animada; e progress
, do tipo Animatable
, que realizará a atualização do progresso por meio de uma animação.
Na definição da Row
, foi adicionado a quantidade de barras de progresso que serão exibidas. Cada barra é representada por um LinearProgressIndicator
(da biblioteca do Material Design para Compose) de acordo com o parâmetro stepCount
. Perceba a lógica que foi adicionada para exibir o progresso: se a barra de progresso for do elemento atual (i == currentStepState
), então use o progresso indicado na animação (progress.value
); caso seja de um elemento do story que ainda será exibido, o progresso será 0 (não iniciado). Mas se for um elemento do story que já foi exibido, o progresso é 1 (concluído).
Para executar a animação, foi utilizada a função LaunchedEffect
. Perceba que foi utilizado um for
para animar as barras sequencialmente. Isso é uma coisa bem bacana do Compose! As animações são executadas como suspended functions, ou seja, quando a função progress.animateTo
é chamada, a próxima linha só é executada quando a animação é concluída. Finalmente, animação é movida para o estado inicial (progress.snapTo(0)
) e o índice que representa o elemento atual do story é incrementado (currentStepState += 1
) para que a próxima animação inicie.
Para usar esse componente é simples:
@Composable
fun MyInstagramScreen() {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
MyInstagramProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
stepCount = 5,
stepDuration = 2_000,
unselectedColor = Color.LightGray,
selectedColor = Color.Blue
)
}
}
}
O resultado ficará como a seguir:
Controlando o estado da animação
Como mencionado anteriormente, realizar a animação nem foi tão complicado. A parte mais complexa é controlar a animação. Se você utiliza o Instagram, sabe que é possível pausar um story tocando na tela e pode ir para o próximo elemento ou para o elemento anterior do story tocando nas extremidades da tela. Vejamos as mudanças:
@Composable
fun MyInstagramProgressIndicator(
...
currentStep: Int,
onStepChanged: (Int) -> Unit,
onComplete: () -> Unit,
isPaused: Boolean = false
) {
...
O parâmetro currentStep
(como o próprio nome diz) indica qual elemento atual do story. O callback onStepChanged
será invocado quando o progresso do elemento atual do story for concluído. Esse callback será utilizado para mudar o elemento atual do story. O onComplete
, por sua vez, será chamado quando o progresso do último elemento do story for concluído. Por fim, o isPaused
indica se o progresso atual deve ser pausado.
var currentStepState by remember(currentStep) {
mutableStateOf(currentStep)
}
val progress = remember(currentStep) {
Animatable(0f)
}
Continuando com as mudanças, perceba que o currentStep
é passado como parâmetro da função remember
. Esse parâmetro serve como uma chave (key
) para essa função! Isso é muito importante! Vejamos o que diz a documentação desta função:
[a função
remember
] lembra o valor retornado pelo [parâmetro]calculation
se akey1
for igual a composição anterior, caso contrário um novo valor valor será produzido e lembrado chamandocalculation
.
Sendo assim, quando currentStep
for modificado, um novo valor será produzido. Então, currentStepState
receberá o valor de currentStep
; e progress
será recriado com o valor 0. Em outras palavras, quando mudar o elemento atual do story, esses estados serão reiniciados com novos valores.
LaunchedEffect(isPaused, currentStep) {
if (isPaused) {
progress.stop()
} else {
for (i in currentStep until stepCount) {
progress.animateTo(
1f,
animationSpec = tween(
durationMillis =
((1f - progress.value) * stepDuration)
.toInt(),
easing = LinearEasing
)
)
if (currentStepState + 1 <= stepCount - 1) {
progress.snapTo(0f)
currentStepState += 1
onStepChanged(currentStepState)
} else {
onComplete()
}
}
}
}
O LaunchedEffect
segue uma idéia similar ao remember
e recebe duas chaves. Vejamos o que diz a documentação:
A coroutine será cancelada e relançada quando o
LaunchEffect
for renderizado novamente (recomposed) com umakey1
oukey2
diferente.
Desta forma, quando os parâmetros isPaused
ou currentStep
forem atualizados, o LaunchedEffect
anterior será cancelado e um novo será lançado.
Quando a barra de progresso for pausada, a animação é parada. A lógica da duração (durationMillis
) também foi modificada de modo que, caso o usuário pause a animação e a retome em seguida, ela continuará o progresso de onde parou.
Finalmente, ao terminar cada animação, a lógica para chamar os callbacks onStepChanged
e onComplete
são apropriadamente invocados.
As seguintes alterações devem ser feitas para visualizar as mudanças em execução:
@Composable
fun MyInstagramScreen() {
val stepCount = 5
var currentStep by remember {
mutableStateOf(0)
}
var isPaused by remember {
mutableStateOf(false)
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
MyInstagramProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
stepCount = stepCount,
stepDuration = 2_000,
unselectedColor = Color.LightGray,
selectedColor = Color.Blue,
currentStep = currentStep,
onStepChanged = { currentStep = it },
isPaused = isPaused,
onComplete = { /* TODO */ }
)
Button(onClick = { isPaused = !isPaused }) {
Text("Play/Pause")
}
Button(
onClick = {
currentStep =
min(currentStep + 1, stepCount - 1)
}
) {
Text("Next")
}
Button(
onClick = {
currentStep = max(currentStep - 1, 0)
}
) {
Text("Prev")
}
}
}
}
A tela precisa controlar qual o elemento atual do story (currentStep
) e se o progresso está pausado ou não (isPaused
). Esse valores são passados para o MyInstagramProgressIndicator
via parâmetro. Note que o estado atual é atualizado via callback onStepChanged
, mantendo-o sincronizado com o componente.
Em seguida, três botões foram declarados para simular os comportamentos de pausar/continuar a animação; ir para o próximo elemento do story; e ir para o elemento anterior. O resultado pode ser observado abaixo:
Controlando a animação com eventos de toque
A maior parte do trabalho foi feita. Como bônus, vou demonstrar como tratar esses eventos usando os eventos de toque.
@Composable
fun MyInstagramScreen() {
val images = remember {
listOf(
R.drawable.img_london,
R.drawable.img_ba,
R.drawable.img_paris,
R.drawable.img_sfo,
R.drawable.recife
)
}
val stepCount = images.size
var currentStep by remember {
mutableStateOf(0)
}
var isPaused by remember {
mutableStateOf(false)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val imageModifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = { offset ->
currentStep =
if (offset.x < constraints.maxWidth/2) {
max(0, currentStep - 1)
} else {
min(stepCount - 1, currentStep + 1)
}
isPaused = false
},
onPress = {
try {
isPaused = true
awaitRelease()
} finally {
isPaused = false
}
},
onLongPress = {
// must be here to avoid call onTap
// for play/pause behavior
}
)
}
Image(
painter = painterResource(id = images[currentStep]),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = imageModifier
)
MyInstagramProgressIndicator(...) // No changes here
}
}
O componente BoxWithConstraints
foi utilizado porque é preciso saber se o usuário clicou na extremidade esquerda ou direita para exibir o elemento anterior ou posterior do story. Esse componente fornece o seu tamanho e do parent, e por meio do atributo constraint
é possível realizar essa lógica.
No imageModifier
será definido os eventos de toque. Por meio do modificador pointerInput
é possível invocar a função detectTapGestures
. Nela, definimos que no onTap
será exibido o próximo elemento ou o elemento anterior do story. No onPress
, o story é pausado. Perceba que foi utilizado um try/catch
pois a função awaitRelease
fica aguardando que o libere o toque na tela. Caso o evento seja cancelado (por exemplo, deslizando o dedo pra fora do aparelho) uma exceção é levantada, assim a animação deve ser retomada ao atribuir o valor de isPaused
para false
no bloco finally
. Por fim, o onLongPress
é definido, mesmo vazio, apenas para evitar que o onPress
seja chamado caso o usuário fique segurando o dedo na tela.
Vejamos abaixo o resultado final:
O código completo pode ser encontrado aqui.
Conclusão
Mexer com o Jetpack Compose tem sido muito divertido e aprendo coisas novas à cada dia. Esse exemplo demonstra a importância do parâmetro key
para as funções remember
e LaunchedEffect
. E sem dúvida, ter que replicar comportamentos de aplicações existentes ajuda muito a aprender coisas novas.
Espero que tenham gostado e qualquer dúvida, deixem seus comentários.
4br4ç05,
nglauber