在 Jetpack Compose 中实现自定义分页指示器

在 Jetpack Compose 中实现自定义分页指示器_第1张图片

在 Jetpack Compose 中实现自定义分页指示器

在 Jetpack Compose 中,我们可以创建自定义的分页指示器,从而增强用户体验。分页指示器是指在应用程序中使用的指示器,用于显示当前活动页的位置,通常在滑动视图或 ViewPager 中使用。使用 Jetpack Compose,我们可以轻松地定制这些指示器,使其适应我们的应用程序的外观和感觉。在这篇文章中,我们将学习如何实现自定义的分页指示器,从而提升用户体验。

背景

分页指示器在引导用户浏览应用程序中的多个屏幕或页面方面起着重要作用。虽然 Jetpack Compose 提供了许多内置组件,但自定义分页指示器以匹配应用程序的独特样式和品牌可以提升用户体验。

在本博客文章中,我们将探讨如何在 Jetpack Compose 中创建和实现自定义的分页指示器,使您可以为应用程序的导航增添独特的风格。

本博客中我们将实现什么内容?

让我们开始吧…
我已经使用 Canvas API 实现了大多数指示器。此外,为了展示替代方法,我还使用了内置的 composable,如 Box。

另外,我们将突出分页指示器的灵活性,并展示如何使用统一的逻辑来实现它们。

让我们来看一下在所有指示器中使用的常见值的计算方法。

// To get scroll offset
val PagerState.pageOffset: Float
    get() = this.currentPage + this.currentPageOffsetFraction


// To get scrolled offset from snap position
fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {
    return (currentPage - page) + currentPageOffsetFraction
}

这是一个在画布上绘制指示器的典型扩展函数。 现在,让我们开始实现酷炫的指示器。

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)
}

扩展的线条/点指示器

为了实现展开/折叠效果,我们只需要根据页面偏移量来对指示器的宽度进行动画处理。

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
        }
    }
  • 变量 x 被初始化为 0,表示绘制指示器的起始 x 坐标。
  • 变量 y 被赋值为绘制指示器的 y 坐标,计算方法是画布的中心 y 坐标。
  • posOffset 表示从 pagerState.pageOffset 获取的分页偏移量。使用取模运算符 % 1 计算 - dotOffset,dotOffsetposOffset 的小数部分。current 被赋值为 posOffset 的整数部分。
    - factor 决定了指示器宽度在页面内的当前位置的调整。
  • 最后,更新 x 来计算下一个指示器的起始位置。
    下面是结果。

滑动指示器

对于这种指示器,我们将使用 Box 组合。我们只需要在页面更改时将线条/点水平移动即可。

  • distance 是指示器的宽度加上间距

让我们来看看结果。

蠕虫点指示器

对于这个指示器,我们同样会使用 Box 组合。我们只需要改变当前指示器的宽度,以实现蠕虫效果。让我们为它创建一个修饰符。

@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)
    }

在这里,我们计算了指示器的左侧和右侧位置,并在 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)
        )
    }
}

结果如下:

跳跃点指示器

这个指示器的实现与前面的指示器非常相似。

在这里,我们将使用graphicsLayer修饰符,改变指示器的 X 位置和缩放,以实现类似于这样的效果…,

让我们来看看 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
    }

在这里,我们计算了当前页面偏移量(pageOffset),并将其加到 pagerState 的当前页面索引(currentPage)上。这提供了 pager 的精确滚动位置。

然后,我们将该值乘以点指示器的大小(size.width)再加上额外的 8 dp(点之间的间距),并将结果赋给 graphics layer 的translationX属性。这个变换使得点指示器在pager滚动时实现水平移动。

接着,我们使用 if-else 条件根据pageOffset计算缩放比例。如果 pageOffset 的绝对值小于 0.5(表示点位于屏幕中心),我们将缩放从 1.0f 插值线性过渡到目标缩放值。另一方面,如果 pageOffset 的绝对值大于或等于 0.5,我们则反向插值,使得点平滑过渡回原始大小。

将这个修饰符用法与前面的 wormTransition 一样。

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

弹跳点指示器

这个指示器与前面的指示器非常相似。我们将使用相同的缩放和平移 x 值的逻辑。此外,我们还将改变 Y 位置以实现弹跳效果。让我们看看如何实现。我们通过将 pageOffset 的绝对值乘以 Math.PI 来计算一个因子。这个因子控制着点在弹跳运动期间的垂直移动。

根据 current 和 settledPage 之间的关系,我们确定弹跳的方向。如果 current 大于或等于 settledPage,则使用正弦函数乘以jumpOffset来计算一个负的 y 值。否则,我们使用正弦函数乘以距离的一半来计算一个正的 y 值。这样,在 pager 滚动回到之前的页面时,会在相反方向上产生弹跳效果。

将这个修饰符用法与前面的指示器一样,就可以实现这样的效果。

交换点指示器

在这里,我们使用 Canvas 组合来绘制点指示器。Canvas 的宽度是根据指示器的总数和它们的组合宽度和间距来计算的。在 Canvas 范围内,

posOffset 表示当前页面位置和分数偏移。
dotOffset 捕获 posOffset 的小数部分,表示当前页面内的偏移。
current posOffset 的整数部分,表示当前页面索引。
moveX 决定了点指示器的水平位置。它根据icurrent 之间的关系进行不同的计算。如果 i 等于 current,则使用 posOffset 作为位置。如果 i - 1 等于 current,则使用 i - dotOffset 作为位置。否则,使用 i 作为位置。
让我们看看输出。
在 Jetpack Compose 中实现自定义分页指示器_第2张图片

显示点指示器

我们将看到两种不同的效果。

效果 #1
此效果渲染出根据 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
                )
            }
        }
    }
}

scale 变量根据 pageOffset 的绝对值确定点指示器的缩放比例,radius 表示基于circleSizescale 的缩放半径。这创建了一个缩放效果,点指示器在 pager 状态变化时逐渐增大和显现出来,效果如上图所示。

效果 #2
在上述效果中,我们绘制了一个带有 Stroke 样式的圆圈,在效果 #1 的基础上进行简单修改即可得到另一种效果。

@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)
            }
        }
    }
}

结果如下:

结论

希望本文为您提供了有价值的见解和在 Jetpack Compose 项目中尝试自定义 pager 指示器的灵感。

现在,是时候将您的应用程序导航提升到新的水平,给用户留下深刻的印象了。

你可能感兴趣的:(jetpack,compose,android,android,jetpack)