Jetpack compose – Como implementar indicadores de pager personalizados
Conteudo
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 depagerState.pageOffset
.dotOffset
é calculado como a parte decimal deposOffset
usando o operador de módulo%
1.current
é atribuído à parte inteira deposOffset
.- 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 deposOffset
que representa o deslocamento dentro da página atual.current
é a parte inteira deposOffset
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 entrei
ecurrent
. Sei
for igual acurrent
, ele usaráposOffset
como a posição. Sei - 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!