Jetpack Compose:使用PagerIndicator和Infinity实现滚动的HorizontalPager
可能你已经知道,Jetpack Compose 默认不包含内置的ViewPager组件。然而,我们可以通过在 build.gradle 文件中添加 accompanist 库依赖,将 ViewPager 功能集成到我们的项目中。
implementation "com.google.accompanist:accompanist-pager:0.28.0"
为了将指示器纳入其中,我们还将利用accompanist库。
implementation "com.google.accompanist:accompanist-pager-indicators:0.28.0"
注意:对于此项目,我们使用的是Compose版本1.3.1和Kotlin版本1.8.10。
让我们从创建一个HorizontalPager 开始
accompanist库的存在,创建HorizontalPager是一项简单的任务。
HorizontalPager(
count = pageCount,
state = pagerState,
modifier = modifier
) {
// page content
}
- Count:页面数
我们将计数设置为非常大的数,如Int.MAX_VALUE
,这样我们就可以实现无限滚动行为。
// Used Int.MAX_VALUE for infinity scroll
val pageCount = Int.MAX_VALUE
- State:用于控制或观察分页器状态的状态对象。
对于状态创建,我们只需要 initialPage 值。为了实现双向无限滚动,我们应该从给定页面计数的中间开始。因此,initialPage 可以设置如下示例:
val middlePage = pageCount / 2
val pagerState = rememberPagerState(initialPage = middlePage)
尽管一开始所有东西似乎都是正确的,但我们很快就会遇到一个涉及最初显示页面的问题。
技巧
为了实现ViewPager的无限行为,我们通过设置计数为一个非常大的数字来实现了一种解决方案。但是,我们实际的物品列表(奖杯)要小得多。为了确保ViewPager显示我们真正列表中的正确页面而不创建重复页面,我们需要适当地处理页面编号。
为了解决这个挑战,我们将提供的页面编号除以奖杯列表的大小。这个除法允许我们在我们的真实列表中获取正确的页面索引。通过执行这个计算,我们确保ViewPager只显示列表中的实际物品,防止任何重复。
通过利用这种方法,我们可以在ViewPager中轻松导航通过奖杯列表,同时保持其无限行为。
看下面的例子:
val realSize = trophies.size
HorizontalPager(
count = pageCount,
state = pagerState,
modifier = modifier
) { page ->
val realPage = page % realSize
// max value is trophies.size
TrophyWidget(realPage, trophy = trophies[realPage])
}
你懂了吗?不懂?(那我们就来算一下吧!)
看起来我们的数学运算正常运行!
如果您仔细观察,就会发现初始页面不是奖杯列表中的第一个。实际上,初始状态取决于奖杯列表的大小。为了解决这个差异并确保正确的初始状态,有必要计算并传递一个参数到ViewPager状态。
val realSize = trophies.size
val middlePage = pageCount / 2
// Init the PagerState with a very large number and make it always start from the first item of the real list
val pagerState = rememberPagerState(initialPage = middlePage - (middlePage % realSize))
通过将middlePage
减去middlePage
与奖杯数量取余的结果,确保ViewPager
将从奖杯列表的开头开始。
页面指示器
添加指示器也很简单,我们只需要添加HorizontalPagerIndicator
并将pagerState
作为参数传递即可。
然而,这里存在一个问题!如果您尝试在不指定列表(奖杯)的实际大小的情况下使用pagerState
,则应用程序将会空白页。那是因为HorizontalPagerIndicator
的默认pageCount
设置为PagerState.pageCount
的值,而在我们的情况下,这是一个非常大的数。
幸运的是,我们可以通过将pageCount
作为参数添加到HorizontalPagerIndicator
中来指定pageCount
。
看一个例子:
HorizontalPagerIndicator(
pagerState = pagerState,
pageCount = realSize,
pageIndexMapping = { it % realSize },
activeColor = Color.White,
modifier = modifier
.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
)
我们还必须描述如何通过将页面传递给pageIndexMapping
函数来获取活动指示器的位置。这可以通过将pagerState.currentPage
除以奖杯列表的大小来实现。
如上例所示,您可以实现以下代码段:
pageIndexMapping = { currentPage % realSize }
要获取活动指示器的位置,您可以使用pageIndexMapping
函数,并使用pagerState.currentPage
和奖杯列表的大小执行模运算。
自动滚动
如果您还需要您的页面自动滚动,可以使用以下代码片段:
// Start auto-scroll effect
LaunchedEffect(isDraggedState) {
// convert compose state into flow
snapshotFlow { isDraggedState.value }
.collectLatest { isDragged ->
// if not isDragged start slide animation
if (!isDragged) {
// infinity loop
while (true) {
// duration before each scroll animation
delay(5_000L)
runCatching {
pagerState.animateScrollToPage(pagerState.currentPage.inc() % pagerState.pageCount)
}
}
}
}
}
完整代码如下:
private const val SCROLL_ANIMATION_DURATION = 5_000L
@OptIn(ExperimentalPagerApi::class)
@Composable
fun InfinityHorizontalPager(modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(400.dp)
) {
// Used Int.MAX_VALUE for infinity scroll
val pageCount = Int.MAX_VALUE
// The actual view pager size (for the HorizontalPagerIndicator)
val realSize = trophies.size
// Start from the middle in order to the infinity scroll for both sides
val middlePage = pageCount / 2
// Init the PagerState with a very large number and make it always start from the first item of the real list
val pagerState = rememberPagerState(initialPage = middlePage - (middlePage % realSize))
val isDraggedState = pagerState.interactionSource.collectIsDraggedAsState()
HorizontalPager(
count = pageCount,
state = pagerState,
modifier = modifier
.fillMaxWidth()
.fillMaxHeight()
.background(MaterialTheme.colors.background),
) {
val page = it % realSize
// max value is trophies.size
TrophyWidget(page, trophy = trophies[page])
}
Surface(
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.BottomCenter),
shape = CircleShape,
color = Color.Black.copy(alpha = 0.5f)
) {
HorizontalPagerIndicator(
pagerState = pagerState,
pageCount = realSize,
pageIndexMapping = { it % realSize },
activeColor = Color.White,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
// Start auto-scroll effect
LaunchedEffect(isDraggedState) {
// convert compose state into flow
snapshotFlow { isDraggedState.value }
.collectLatest { isDragged ->
// if not isDragged start slide animation
if (!isDragged) {
// infinity loop
while (true) {
// duration before each scroll animation
delay(SCROLL_ANIMATION_DURATION)
runCatching {
pagerState.animateScrollToPage(pagerState.currentPage.inc() % pagerState.pageCount)
}
}
}
}
}
}
}
}
@Composable
fun TrophyWidget(
page: Int,
trophy: TrophyCard,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.fillMaxHeight()
.background(Color.Black)
.clip(shape = RoundedCornerShape(size = 12.dp)),
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(trophy.image)
.crossfade(true)
.build(),
modifier = modifier
.fillMaxWidth()
.fillMaxHeight()
.clip(shape = RoundedCornerShape(size = 12.dp)),
contentDescription = null,
contentScale = ContentScale.FillBounds
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = Color.Black.copy(alpha = 0.5f))
.padding(10.dp)
.align(Alignment.BottomStart)
) {
Text(
text = trophy.location,
color = Color.White,
style = Typography.h6,
textAlign = TextAlign.Center
)
Text(
text = trophy.year,
color = Color.White,
style = Typography.h4,
textAlign = TextAlign.Center
)
}
Text(
text = "$page",
style = Typography.body1,
color = Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(10.dp)
.clip(shape = RoundedCornerShape(size = 4.dp))
.background(Color.White)
.padding(10.dp)
.align(Alignment.BottomEnd)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultPreview() {
InfinityPagerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
InfinityHorizontalPager()
}
}
}