[翻译]自定义Compose Pager,加上亿点指示器和动画

翻译不一定很好,欢迎阅读 原文 ,也欢迎指出错误!

感激不尽


随着Compose March 2023 release 的发布, HorizontalPagerVerticalPager 这两个组件也一同加入了 Compose 家族中。虽然 官方文档 中已经说明了如何使用 pager ,但它毕竟是官方文档,给出的示例还是相对简单了些。接下来让我们一起看一下如何为 pager 组件添加一些有意思的指示器和过渡效果。

页面过渡效果 🔀

在官方文档中提到了如何获取一个页面距离"Snapped Position"的位置差,我们可以利用这个位置差信息在两个 page 之间创建过渡效果。

举个例子,我们想在两个 page 切换的过程中添加一个简单的淡入淡出,只需要添加一个 graphicsLayer 的 modifier 给 Composeable Page,让他调整自己的 alphatranslationX 属性:

kotlin 复制代码
val pagerState = rememberPagerState(pageCount = { 10 })
HorizontalPager(
    modifier = modifier.fillMaxSize(),
    state = pagerState
) { page ->
    Box(Modifier
        .graphicsLayer {
            // 译者按:截止2023.8.1,Compose 已提供 currentPageOffsetFraction() 用于直接获取offset
            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 这个 modifier 提取出来,这样 HorizontalPager 组件就可以复用了:

kotlin 复制代码
// 译者按:因为calculateCurrentOffsetForPage已经被currentPageOffsetFraction替代
// 所以参数 page 就没有必要再传入了
fun Modifier.pagerFadeTransition(page: Int, pagerState: PagerState) = 
    graphicsLayer {
        val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
        translationX = pageOffset * size.width
        alpha = 1 - pageOffset.absoluteValue
    }

OK,这下我们使用 Compose 也做到了在之前的 View 体系下的使用 ViewPager 能做到的效果。

其他有意思的过渡特效 🪄

除了上面的淡入淡出,一些之前使用ViewPager实现的其他常见效果,其实在Compose下使用Pager组件同样也能实现。再看看下面的例子:

倾斜向内切换

倾斜向外切换

指尖陀螺切换

这些效果的demo可以在这个 repo 中看到。除了上面图片展示的几个效果,repo中还有一些别的效果,可以去看看。

Rebecc,但你刚刚说的这些都只是一些很简单的东西,我们希望Compose不止能做到过去我们在View当中能做到的事情 🙄我知道你很急,但是你先别急。呐,我们现在就来用Compose来做一些在View体系下比较难做到事情

根据不同的位置去调整Pager中的Composable Content

在过去,大家已经习惯于去调整 page 去实现动画,但 Compose 最牛逼的地方在于 ------ 我们还可以调整 page 里面的 content 的 PagerState。我们可以利用这个属性去实现一些很好玩的、很高级的动画,比如根据页面的滚动状态去隐藏/显示 content,甚至对 content 进行缩放。

让我们来看一下如何利用这个特性去实现一个超级无敌酷炫的动画设计(视频)

首先,我们创建这个动画会用到的组件 ------ HorizontalPager,并把它放在一个 Box 里。再使用 ImageText 组件放在page内作为卡片的内容。

kotlin 复制代码
@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DribbbleInspirationPager() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFFECECEC))
    ) {
        val pagerState = rememberPagerState(pageCount = { 10 })
        HorizontalPager(
            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
                )
            }

        }
    }
}

现在我们创建好了所有需要的组件,接下来就开始分析组件中哪些部分会有动画,并调整动画效果对应的属性。最先被注意到的肯定就是卡片内的Image会根据当前是否被选中而缩放;再一个就是卡片的尺寸会变大,并且在变大的时候显示提示文字 "drag to listen"。 为了实现这两个效果,我们可以和前面的例子一样,使用 pagerState.currentPageOffsetFractionpagerState.currentPage 这两个属性对卡片中的产生动画效果部分的属性进行调整。

我们可以使用 Modifier.graphicsLayer { } 去调整page中的 Image组件:

kotlin 复制代码
@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.currentPageOffsetFraction,我们可以知道 page距离被吸附的位置已经移动了多少距离 。之后可以通过对 pageOffset 进行缩放,把它的值域控制在 1f1.75f 之间,我们可以把这个值同时对 ImagescaleX and scaleY 属性进行操作。当未选中时,缩放比例为 1.75f;当选中时,缩放比例为 1f

结果如下:

接下来就是去实现卡片内展示/隐藏 "drag to listen" 提示文字的部分。这次我们同样使用 pageOffset 的值,并对 Columnalpha进行调整:

kotlin 复制代码
@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)
        )
    }
}

我们现在可以看到,卡片的高度会跟着 page 移动而产生变化:

奈斯!--- 我们现在完整实现了设计原稿上的动效!完整的源码可以在 这里 查看

页面指示器 🚦

通过编写前面提到的动画,我们学会了使用 PagerState 属性,并将其与 content 的属性结合到了一起。除此之外,pager 另一个常见的需求是添加一个页面指示器,用来显示现在在列表中的位置. 现在我们试一下用Compose和 PagerState去实现它,其实非常简单。

我们可以跟随Google提供的文档去做一个简单的页面指示器 ------ 为每个页面画一个小圆圈;也可以创建自定义页面指示器,比如:一个在屏幕下方分段的线条。

让我们来试一下这个效果:

首先,我们在 Box 内创建一个 HorizontalPager,并且将小圆圈样式的指示器移动到底部,让它和页面内的内容产生重叠:

kotlin 复制代码
@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicatorExample() {
    Box(modifier  = Modifier.fillMaxSize()) {
        val pageCount = 5
        val pagerState = rememberPagerState(pageCount = { pageCount })
        HorizontalPager(
            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)

                )
            }
        }
    }
}

接下来,我们要把这个指示器做一些更改,让圆形变成一个直线,并且在不同选中状态下展现出不同的颜色和尺寸,线条的初始 weight 为 1f

kotlin 复制代码
@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicatorExample() {
    Box(modifier  = Modifier.fillMaxSize()) {
        val pageCount = 5
        val pagerState = rememberPagerState(pageCount = { pageCount })
        HorizontalPager(
            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)
                )
            }
        }
    }
}

现在每一个page都会有一个直线可以被用来表示,而且不用修改它的大小:

我们现在给指示器的长度也加上动效:当被选中时,它应该是最长的。现在给 weight 设置动画,让指示器在"选中页面右侧"的 1f 与"选中"的 1.5f 和"选中页面左侧"的 0.5f 之间进行选择。使用 animateFloatAsStateweight 在这些权重之间进行调整:

完整的代码可以在 这里 查看。

结语

正如我们在这篇文章中所提到的,我们可以发现使用 Compose 的 PagerState 可以灵活地创建更复杂的页面交互,而这在以前是很难实现的。利用 pagerState.currentPagepagerState.currentPageOffsetFraction 变量,我们可以创建相当复杂的动效和页面指示器。

如果你自己也实现了自定义的page切换动画或者指示器,请务必来 androiddev.social/riggaroo 留个言分享给我

希望你有个愉快的 Compose 体验 👋

相关推荐
开发者阿伟1 天前
Android Jetpack DataBinding源码解析与实践
android·android jetpack
alexhilton7 天前
Android技巧:学习使用GridLayout
android·kotlin·android jetpack
Wgllss14 天前
轻松搞定Android蓝牙打印机,双屏异显及副屏分辨率适配解决办法
android·架构·android jetpack
alexhilton21 天前
群星闪耀的大前端开发
android·kotlin·android jetpack
一航jason1 个月前
Android Jetpack Compose 现有Java老项目集成使用compose开发
android·java·android jetpack
帅次1 个月前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
IAM四十二1 个月前
Jetpack Compose State 你用对了吗?
android·android jetpack·composer
Wgllss1 个月前
那些大厂架构师是怎样封装网络请求的?
android·架构·android jetpack
x0242 个月前
Android Room(SQLite) too many SQL variables异常
sqlite·安卓·android jetpack·1024程序员节
alexhilton2 个月前
深入理解观察者模式
android·kotlin·android jetpack