在 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
}
}
posOffset
表示从 pagerState.pageOffset
获取的分页偏移量。使用取模运算符 % 1 计算 - dotOffset,dotOffset
是 posOffset
的小数部分。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
决定了点指示器的水平位置。它根据i
和 current
之间的关系进行不同的计算。如果 i 等于 current
,则使用 posOffset
作为位置。如果 i - 1
等于 current
,则使用 i - dotOffset
作为位置。否则,使用 i 作为位置。
让我们看看输出。
我们将看到两种不同的效果。
效果 #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
表示基于circleSize
和 scale
的缩放半径。这创建了一个缩放效果,点指示器在 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 指示器的灵感。
现在,是时候将您的应用程序导航提升到新的水平,给用户留下深刻的印象了。