Android使用有趣的指示器和过渡自定义 Compose Pager
google最近在compose中新增了Pager控件,HorizontalPager
和VerticalPager
。
页面之间的转换
该文档涵盖了访问页面从“对齐”位置滚动到多远的基础知识。我们可以使用这些信息来创建页面之间的过渡效果。
例如,如果我们想在页面之间创建一个简单的淡入淡出效果,我们可以将修饰符应用于graphicsLayer
我们的页面可组合项以调整其alpha和translationX
:
val pagerState = rememberPagerState()
HorizontalPager(
pageCount = 10,
modifier = modifier.fillMaxSize(),
state = pagerState
) { page ->
Box(Modifier
.graphicsLayer {
val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
// translate the contents by the size of the page, to prevent the pages from sliding in from left or right and stays in the center
translationX = pageOffset * size.width
// apply an alpha to fade the current page in and the old page out
alpha = 1 - pageOffset.absoluteValue
}
.fillMaxSize()) {
Image(
painter = rememberAsyncImagePainter(model = rememberRandomSampleImageUrl
(width = 1200)),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
.padding(16.dp)
.clip(RoundedCornerShape(16.dp)),
)
}
}
// extension method for current page offset
@OptIn(ExperimentalFoundationApi::class)
fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {
return (currentPage - page) + currentPageOffsetFraction
}
然后我们可以将这个graphicsLayer
修饰符提取到一个可重用的修饰符中,我们可以在其他HorizontalPager
实例上使用它:
fun Modifier.pagerFadeTransition(page: Int, pagerState: PagerState) =
graphicsLayer {
val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
translationX = pageOffset * size.width
alpha = 1- pageOffset.absoluteValue
}
这太棒了,我们可以实现我们之前在视图中能够实现的相同效果ViewPager
。
其他有趣的过渡效果
您可以使用 ViewPager
实现的一些更常见的效果,也可以使用 Compose 中的 Pager
实现,例如:
立方体深度效果
立方体深度效果
指尖陀螺效果
更多特效请查看github repo代码
https://github.com/riggaroo/compose-playtime#custom-pager-transformations
Compose 的美妙之处在于我们PagerState也可以访问页面内部的内容。因此我们可以使用此信息来执行有趣的效果,例如驱动动画、隐藏/缩放内容或根据页面的滚动状态显示内容。
下面实现音乐播放界面效果
[video width=“1600” height=“1200” mp4=“http://www.lovecodeboy.top/wp-content/uploads/2023/04/dribbble.mp4”][/video]
首先,我们创建此屏幕组件的静态版本,方法是创建HorizontalPager
内部 aBox
和 staticImage
和Text
可组合项来表示每个页面。
@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DribbbleInspirationPager() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFECECEC))
) {
val pagerState = rememberPagerState()
HorizontalPager(
pageCount = 10,
pageSpacing = 16.dp,
beyondBoundsPageCount = 2,
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
Box(modifier = Modifier.fillMaxSize()) {
// Contains Image and Text composables
SongInformationCard(
modifier = Modifier
.padding(32.dp)
.align(Alignment.Center),
pagerState = pagerState,
page = page
)
}
}
}
}
现在我们有了歌曲的静态版本,我们可以进一步分析设计,看看可组合项的哪些部分是动画的。您可能会注意到的第一件事是卡片内的图像会随着它是否为当前选定的项目而变大。滚动的下一个变化是卡片的尺寸扩大,并在展开时显示“拖动以收听”文本。要实现这两个元素,我们可以使用相同的值pagerState.currentPageOffsetFraction
,并pagerState.currentPage
在页面可组合项内容的不同部分使用这些值。
要调整可组合项内的图像,我们使用Modifier.graphicsLayer { }
on Image
。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SongInformationCard(
pagerState: PagerState,
page: Int,
modifier: Modifier = Modifier
) {
Card(
modifier = /*..*/
) {
Column(modifier = /*..*/) {
val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
Image(
modifier = Modifier
/* other modifiers */
.graphicsLayer {
// get a scale value between 1 and 1.75f, 1.75 will be when its resting,
// 1f is the smallest it'll be when not the focused page
val scale = lerp(1f, 1.75f, pageOffset)
// apply the scale equally to both X and Y, to not distort the image
scaleX = scale
scaleY = scale
},
//..
)
SongDetails()
}
}
}
通过获取 pagerState.currentPage
并减去当前歌曲所在的页数,我们可以知道歌曲距离 pager
(当前选中的页)中心的偏移量。接着,我们将这个值与pagerState.currentPageOffsetFraction
相加,现在我们知道页面从其对齐位置滚动的比例。然后,我们可以将 pageOffset
在 1f 和 1.75f 之间进行缩放。这个值会被用来应用 scaleX
和 scaleY
,以便不会对图像进行扭曲。当页面没有被选中时,缩放值为 1.75f,当页面被选中时,缩放值为 1f。
结果如下:
下一步是展开卡片以显示和隐藏卡片中的“拖动以收听”部分。使用相同的 pageOffset
值,我们可以对 Column 组合进行高度的动画变化,同时也可以对透明度进行动画效果的变化。
@Composable
private fun DragToListen(pageOffset: Float) {
Box(
modifier = Modifier
.height(150.dp * (1 - pageOffset))
.fillMaxWidth()
.graphicsLayer {
alpha = 1 - pageOffset
}
) {
Column(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Rounded.MusicNote, contentDescription = "",
modifier = Modifier
.padding(8.dp)
.size(36.dp)
)
Text("DRAG TO LISTEN")
Spacer(modifier = Modifier.size(4.dp))
DragArea()
}
}
}
@Composable
private fun DragArea() {
Box {
Canvas(
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(bottomEnd = 32.dp, bottomStart = 32.dp))
) {
val sizeGap = 16.dp.toPx()
val numberDotsHorizontal = size.width / sizeGap + 1
val numberDotsVertical = size.height / sizeGap + 1
repeat(numberDotsHorizontal.roundToInt()) { horizontal ->
repeat(numberDotsVertical.roundToInt()) { vertical ->
drawCircle(
Color.LightGray.copy(alpha = 0.5f), radius = 2.dp.toPx
(), center =
Offset(horizontal * sizeGap + sizeGap, vertical * sizeGap + sizeGap)
)
}
}
}
Icon(
Icons.Rounded.ExpandMore, "down",
modifier = Modifier
.size(height = 24.dp, width = 48.dp)
.align(Alignment.Center)
.background(Color.White)
)
}
}
运行这个代码,我们可以看到每张卡片的高度现在也受到页面被拖动的程度的驱动:
太棒了 - 我们已经实现了与原始设计一样的页面动画效果!完整的源代码可以在这里找到。
https://github.com/riggaroo/compose-playtime/blob/main/app/src/main/java/dev/riggaroo/composeplaytime/pager/DribbbleInspirationPager.kt
页面指示器
现在我们已经了解了如何访问PagerState并使用其转换内容,另一个常见的使用Pager的用例是添加一个指示器来显示当前所在的页面在页面列表中的位置。使用Compose和PagerState,获取这些信息非常简单。
要创建一个基本的页面指示器,我们可以像文档建议的那样,为每个页面绘制一个圆圈。但是,我们还可以创建自己的自定义页面指示器,例如在屏幕底部分段的线条,我们可以更改绘制逻辑以绘制线条而不是圆圈,并将宽度均匀地划分为若干段。
从下面这个页面效果得到灵感
我们要做的第一件事是在 一个HorizontalPager
内部创建一个Box
,并将我们的圆形指示器移动到框的底部,与分页器内的内容重叠:
@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicatorExample() {
Box(modifier = Modifier.fillMaxSize()) {
val pageCount = 5
val pagerState = rememberPagerState()
HorizontalPager(pageCount = pageCount,
beyondBoundsPageCount = 2,
state = pagerState) {
PagerSampleItem(page = it)
}
Row(
Modifier
.height(50.dp)
.fillMaxWidth()
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.Center
) {
repeat(pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = 0.5f)
Box(
modifier = Modifier
.padding(4.dp)
.clip(CircleShape)
.background(color)
.size(16.dp)
)
}
}
}
}
接下来,我们将更改圆形指示器,以绘制一条线条,如果选中与未选中,则颜色和大小不同。我们最初给每行一个权重1f
。
@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicatorExample() {
Box(modifier = Modifier.fillMaxSize()) {
val pageCount = 5
val pagerState = rememberPagerState()
HorizontalPager(pageCount = pageCount,
beyondBoundsPageCount = 2,
state = pagerState) {
PagerSampleItem(page = it,
modifier = Modifier.pagerFadeTransition(it, pagerState = pagerState))
}
Row(
Modifier
.height(24.dp)
.padding(start = 4.dp)
.fillMaxWidth()
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.Start
) {
repeat(pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = 0.5f)
Box(
modifier = Modifier
.padding(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(color)
.weight(1f)
.height(4.dp)
)
}
}
}
}
这导致每一页的线条,绘制而不改变它们的大小:
现在,线条也需要为它们的长度变化设置动画。如果项目被选中,它应该是最长的一行。我们将为 设置动画weight
,1f在右侧未选定页面、1.5f选定行和0.5f选定页面左侧的页面之间进行选择。我们使用animateFloatAsState
这些权重之间的动画:
repeat(pageCount) { iteration ->
val lineWeight = animateFloatAsState(
targetValue = if (pagerState.currentPage == iteration) {
1.5f
} else {
if (iteration < pagerState.currentPage) {
0.5f
} else {
1f
}
}, label = "weight", animationSpec = tween(300, easing = EaseInOut)
)
val color =
if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = 0.5f)
Box(
modifier = Modifier
.padding(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(color)
.weight(lineWeight.value)
.height(4.dp)
)
}
完整项目源码:
https://github.com/riggaroo/compose-playtime/blob/main/app/src/main/java/dev/riggaroo/composeplaytime/pager/LineIndicatorExample.kt
总结
正如我们在本篇博文中探讨的那样,我们可以看到在Compose中使用PagerState
可以灵活地创建更加复杂的页面交互效果,这在以前是比较复杂的。通过利用pagerState.currentPage
和pagerState.currentPageOffsetFraction
变量,我们可以创建相当复杂的UI交互和页面指示器。