Jetpack compose – Como implementar indicadores de pager personalizados

Tempo de leitura: 9 minutes

Plano de fundo

Os indicadores de pager são vitais para orientar os usuários por várias telas ou páginas em um aplicativo. Embora o Jetpack Compose ofereça uma ampla variedade de componentes incorporados, a personalização dos indicadores de pager para combinar com o estilo e a marca exclusivos do seu aplicativo pode elevar a experiência do usuário.

Nesta publicação do blog, exploraremos como criar e implementar indicadores de pager personalizados no Jetpack Compose, permitindo que você adicione um toque de exclusividade à navegação do seu aplicativo.

O que vamos implementar neste artigo?

 

Vamos começar…

Implementei a maioria dos indicadores usando a API Canvas. Além disso, para demonstrar abordagens alternativas, também implementei alguns usando composições incorporadas, como o Box.

Além disso, destacaremos a flexibilidade dos indicadores de pager e mostraremos como eles podem ser implementados usando uma lógica unificada.

Vamos analisar o cálculo dos valores comuns usados em todos os indicadores.

// Para obter o deslocamento da rolagem
val PagerState.pageOffset: Float
    get() = this.currentPage + this.currentPageOffsetFraction


// Para obter o deslocamento da rolagem a partir da posição de encaixe
fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {
    return (currentPage - page) + currentPageOffsetFraction
}

E aqui está uma função de extensão típica para desenhar indicadores na tela

private fun DrawScope.drawIndicator(
    x: Float,
    y: Float,
    width: Float,
    height: Float,
    radius: CornerRadius
) {
    val rect = RoundRect(
        x,
        y - height / 2,
        x + width,
        y + height / 2,
        radius
    )
    val path = Path().apply { addRoundRect(rect) }
    drawPath(path = path, color = Color.White)
}

Vamos começar a implementar indicadores interessantes.

 

Expansão de indicadores de linha/ponto

Para obter o efeito de expansão/colapso, precisamos apenas animar a largura do indicador com base no deslocamento da página.

Canvas(modifier = Modifier.width(width = totalWidth)) {
      val spacing = circleSpacing.toPx()
      val dotWidth = width.toPx()
      val dotHeight = height.toPx()

      val activeDotWidth = activeLineWidth.toPx()
      var x = 0f
      val y = center.y
      
      repeat(count) { i ->
          val posOffset = pagerState.pageOffset
          val dotOffset = posOffset % 1
          val current = posOffset.toInt()

          val factor = (dotOffset * (activeDotWidth - dotWidth))

          val calculatedWidth = when {
              i == current -> activeDotWidth - factor
              i - 1 == current || (i == 0 && posOffset > count - 1) -> dotWidth + factor
              else -> dotWidth
          }

          drawIndicator(x, y, calculatedWidth, dotHeight, radius)
          x += calculatedWidth + spacing
      }
  }
  • A variável x é inicializada em 0, representando a coordenada x inicial para desenhar os indicadores.
  • A variável y recebe a atribuição da coordenada y para desenhar os indicadores, que é calculada como a coordenada y central da tela.
  • posOffset representa o deslocamento de página fracionário obtido de pagerState.pageOffset. dotOffset é calculado como a parte decimal de posOffset usando o operador de módulo % 1. current é atribuído à parte inteira de posOffset.
  • O fator determina o ajuste da largura do indicador com base na posição atual dentro da página.
  • No final, x é atualizado para calcular a posição inicial do indicador seguinte.

E aqui está o resultado.

 

Indicadores deslizantes

Para esse indicador, usaremos o Box Composables. Só precisamos mover a linha/ponto horizontalmente à medida que a página muda.

@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.slidingLineTransition(pagerState: PagerState, distance: Float) =
    graphicsLayer {
        val scrollPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
        translationX = scrollPosition * distance
    }
  • distance – a largura do indicador + o espaçamento
Box(
       contentAlignment = Alignment.CenterStart
   ) {
       Row(
           horizontalArrangement = Arrangement.spacedBy(spacing),
           verticalAlignment = Alignment.CenterVertically,
       ) {
           repeat(count) {
               Box(
                   modifier = Modifier
                       .size(width = dotWidth, height = dotHeight)
                       .background(
                           color = inactiveColor,
                           shape = RoundedCornerShape(3.dp)
                       )
               )
           }
       }

       Box(
           Modifier
               .slidingLineTransition(pagerState, distance)
               .size(width = dotWidth, height = dotHeight)
               .background(
                   color = activeColor,
                   shape = RoundedCornerShape(3.dp),
               )
       )
   }

Vamos ver o resultado.

 

Indicador Worm Dot

Para esse indicador também, usaremos o Box Composables. Só precisamos alterar a largura do indicador atual para obter um efeito semelhante ao de uma minhoca. Vamos criar um modificador para isso.

@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.wormTransition(
    pagerState: PagerState
) =
    drawBehind {
        val distance = size.width + 10.dp.roundToPx()
        val scrollPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
        val wormOffset = (scrollPosition % 1) * 2

        val xPos = scrollPosition.toInt() * distance
        val head = xPos + distance * 0f.coerceAtLeast(wormOffset - 1)
        val tail = xPos + size.width + 1f.coerceAtMost(wormOffset) * distance

        val worm = RoundRect(
            head, 0f, tail, size.height,
            CornerRadius(50f)
        )

        val path = Path().apply { addRoundRect(worm) }
        drawPath(path = path, color = Color.White)
    }

Aqui, calculamos o valor da posição left e right dos indicadores e desenhamos o caminho no modificador drawBehind.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun WormIndicator(
    count: Int,
    pagerState: PagerState,
    modifier: Modifier = Modifier,
    spacing: Dp = 10.dp,
) {

    Box(
        modifier = modifier,
        contentAlignment = Alignment.CenterStart
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(spacing),
            modifier = modifier
                .height(48.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            repeat(count) {
                Box(
                    modifier = Modifier
                        .size(20.dp)
                        .background(
                            color = Color.White,
                            shape = CircleShape
                        )
                )
            }
        }

        Box(
            Modifier
                .wormTransition(pagerState)
                .size(20.dp)
        )
    }
}

E o resultado é… 🤩

 

Indicador de ponto de salto

A implementação desse indicador é bastante semelhante à anterior.

Aqui, usaremos o modificador graphicsLayer e alteraremos a posição X e a escala do indicador para obter um efeito parecido com este…

Vamos ver o jumpingDotTransition

@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.jumpingDotTransition(pagerState: PagerState, jumpScale: Float) =
    graphicsLayer {
        val pageOffset = pagerState.currentPageOffsetFraction
        val scrollPosition = pagerState.currentPage + pageOffset
        translationX = scrollPosition * (size.width + 8.dp.roundToPx()) // 8.dp - spacing between dots

        val scale: Float
        val targetScale = jumpScale - 1f

        scale = if (pageOffset.absoluteValue < .5) {
            1.0f + (pageOffset.absoluteValue * 2) * targetScale;
        } else {
            jumpScale + ((1 - (pageOffset.absoluteValue * 2)) * targetScale);
        }

        scaleX = scale
        scaleY = scale
    }

Aqui, calculamos o deslocamento da página atual (pageOffset) e o adicionamos ao índice da página atual (currentPage) do pagerState. Isso fornece a posição de rolagem precisa do pager.

Em seguida, atribuímos esse valor multiplicado pelo tamanho do indicador de ponto (size.width) mais um adicional de 8 dp (espaçamento entre pontos) à propriedade translationX da camada gráfica. Essa tradução cria o movimento horizontal do indicador de ponto à medida que o pager rola.

Em seguida, usamos uma condição if-else para calcular a escala com base no pageOffset. Se o valor absoluto de pageOffset for menor que 0,5 (indicando que o ponto está no centro da tela), interpolamos a escala linearmente de 1,0f para targetScale. Por outro lado, se o valor absoluto de pageOffset for maior ou igual a 0,5, reverteremos a interpolação para criar uma transição suave de volta ao tamanho original do ponto.

Use esse modificador como usamos o wormTransition no exemplo de indicador anterior.

Box(
   Modifier
      .jumpingDotTransition(pagerState, 0.8f)
      .size(20.dp)
      .background(
         color = activeColor,
         shape = CircleShape,
      )
)

 

Indicador de pontos saltantes

Esse indicador é bastante semelhante ao indicador acima. Usaremos a mesma lógica de dimensionamento e conversão do valor x. Além disso, alteraremos a posição Y para dar um efeito de salto. Vamos ver como.

private fun Modifier.bounceDotTransition(
    pagerState: PagerState,
    jumpOffset: Float,
    jumpScale: Float
) =
    graphicsLayer {
        val targetScale = jumpScale - 1f
        val distance = size.width + 8.dp.roundToPx()
        val pageOffset = pagerState.currentPageOffsetFraction
        val scrollPosition = pagerState.currentPage + pageOffset
        val current = scrollPosition.toInt()
        val settledPage = pagerState.settledPage

        translationX = scrollPosition * distance

        val scale = if (pageOffset.absoluteValue < .5) {
            1.0f + (pageOffset.absoluteValue * 2) * targetScale;
        } else {
            jumpScale + ((1 - (pageOffset.absoluteValue * 2)) * targetScale);
        }

        scaleX = scale
        scaleY = scale

        val factor = (pageOffset.absoluteValue * Math.PI)
        val y =
            if (current >= settledPage) -sin(factor) * jumpOffset else sin(factor) * distance / 2
        translationY += y.toFloat()
    }

Calculamos um factor multiplicando o valor absoluto de pageOffset por Math.PI. Esse fator controla o movimento vertical do ponto durante o movimento de salto.

Com base na relação entre current e settledPage, determinamos a direção do salto. Se a current for maior ou igual a settledPage, calcularemos um valor y negativo usando a função seno multiplicada por jumpOffset. Caso contrário, calculamos um valor y positivo usando a função seno multiplicada pela metade da distance. Isso cria um efeito de salto na direção oposta quando o pager está rolando de volta para uma página anterior.

Use esse modificador como usamos no indicador anterior para obter um efeito como esse.

 

Troca de indicador de ponto

Aqui usamos o Canvas composable para desenhar os indicadores de ponto. A largura da tela é calculada com base no número total de indicadores e em sua largura e espaçamento combinados.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwapDotIndicators(
    count: Int,
    pagerState: PagerState,
) {
    val circleSpacing = 8.dp
    val circleSize = 20.dp

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(48.dp), contentAlignment = Alignment.Center
    ) {
        val width = (circleSize + circleSpacing) * count

        Canvas(
            modifier = Modifier
                .width(width = width)
        ) {
            val distance = (circleSize + circleSpacing).toPx()

            val dotSize = circleSize.toPx()

            val yPos = center.y

            repeat(count) { i ->
                val posOffset = pagerState.currentPage + pagerState.currentPageOffsetFraction

                val dotOffset = posOffset - posOffset.toInt()
                val current = posOffset.toInt()
                val alpha = if (i == current) 1f else 0.4f

                val moveX: Float = when {
                    i == current -> posOffset
                    i - 1 == current -> i - dotOffset
                    else -> i.toFloat()
                }

                drawIndicator(moveX * distance, yPos, dotSize, alpha)
            }
        }
    }
}

Dentro do escopo do Canvas,

  • posOffset representa a posição da página atual com um deslocamento fracionário.
  • dotOffset captura a parte fracionária de posOffset que representa o deslocamento dentro da página atual.
  • current é a parte inteira de posOffset que representa o índice da página atual.
  • moveX determina a posição horizontal do indicador de ponto. Ele é calculado de forma diferente com base na relação entre i e current. Se i for igual a current, ele usará posOffset como a posição. Se i - 1 for igual ao atual, ele usará i - dotOffset como a posição. Caso contrário, ele usará i como a posição.

Vejamos a saída

 

Indicador de ponto revelador

Veremos dois efeitos diferentes aqui.

Efeito nº 1

Esse efeito renderiza indicadores de pontos que são revelados e escalonados com base no estado do pager.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RevealDotIndicator1(
    count: Int,
    pagerState: PagerState,
    activeColor: Color = Color.White,
) {
    val circleSpacing = 8.dp
    val circleSize = 20.dp
    val innerCircle = 14.dp

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(48.dp), contentAlignment = Alignment.Center
    ) {
        val width = (circleSize * count) + (circleSpacing * (count - 1))

        Canvas(modifier = Modifier.width(width = width)) {
            val distance = (circleSize + circleSpacing).toPx()
            val centerY = size.height / 2
            val startX = circleSpacing.toPx()

            repeat(count) {
                val pageOffset = pagerState.calculateCurrentOffsetForPage(it)

                val scale = 0.2f.coerceAtLeast(1 - pageOffset.absoluteValue)
                val outlineStroke = Stroke(2.dp.toPx())

                val x = startX + (it * distance)
                val circleCenter = Offset(x, centerY)
                val innerRadius = innerCircle.toPx() / 2
                val radius = (circleSize.toPx() * scale) / 2

                drawCircle(
                    color = activeColor,
                    style = outlineStroke,
                    center = circleCenter,
                    radius = radius
                )

                drawCircle(
                    color = activeColor,
                    center = circleCenter,
                    radius = innerRadius
                )
            }
        }
    }
}

A variável scale determina a escala do indicador de ponto com base no valor absoluto de pageOffset e o radius representa o raio dimensionado do indicador de ponto com base em circleSize e scale. Isso cria um efeito de escala em que o ponto cresce e se revela gradualmente à medida que o estado do pager muda desta forma.

 

Efeito nº 2

No efeito acima, desenhamos um círculo com o estilo Stroke. Fazendo modificações simples no efeito nº 1, teremos outro efeito.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RevealDotIndicator2(
    count: Int,
    pagerState: PagerState,
) {
    val circleSpacing = 8.dp
    val circleSize = 20.dp
    val innerCircle = 14.dp

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(48.dp), contentAlignment = Alignment.Center
    ) {
        Canvas(modifier = Modifier) {
            val distance = (circleSize + circleSpacing).toPx()

            val centerX = size.width / 2
            val centerY = size.height / 2

            val totalWidth = distance * count
            val startX = centerX - (totalWidth / 2) + (circleSize / 2).toPx()

            repeat(count) {
                val pageOffset = pagerState.calculateCurrentOffsetForPage(it)

                val alpha = 0.8f.coerceAtLeast(1 - pageOffset.absoluteValue)
                val scale = 1f.coerceAtMost(pageOffset.absoluteValue)

                val x = startX + (it * distance)
                val circleCenter = Offset(x, centerY)
                val radius = circleSize.toPx() / 2
                val innerRadius = (innerCircle.toPx() * scale) / 2

                drawCircle(
                    color = Color.White, center = circleCenter,
                    radius = radius, alpha = alpha,
                )

                drawCircle(color = Color(0xFFE77F82), center = circleCenter, radius = innerRadius)
            }
        }
    }
}

E o resultado…

 

Conclusão

Espero que esta postagem do blog tenha lhe dado insights valiosos e inspiração para experimentar indicadores de pager personalizados em seus projetos do Jetpack Compose.

Agora, é hora de levar a navegação do seu aplicativo para o próximo nível e deixar uma impressão duradoura nos seus usuários.

Boa codificação!