Fonte: https://3c.ltn.com.tw/news/39553

Barra de progresso do Instagram com o Jetpack Compose

Nelson Glauber

--

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 na Row 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 e selectedColor: 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:

Barra de progresso no estilo do Instagram

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 a key1 for igual a composição anterior, caso contrário um novo valor valor será produzido e lembrado chamando calculation.

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 uma key1 ou key2 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 animação da barra de progresso

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:

Exemplo de barra de progresso do Instagram em execução

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

--

--

Nelson Glauber

Android Developer. Google Developer Expert for Android/Kotlin. Author of “Dominando o Android”.