【Jetpack Compose】 TabRow 跟随 HorizontalPager的滑动动画

2,013 阅读1分钟

1. Jetpack Compose 的viewPager

accompanist pager库,这里是它github的地址

这里是用的v0.24.13-rc,对应的comopse版本是1.2.0-rc3

//HorizontalPager and VerticalPager
implementation "com.google.accompanist:accompanist-pager:$accompanist_version"
// If using indicators, also depend on
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"

这是官网的TabRow 和 HorizontalPager的示例代码:

val pagerState = rememberPagerState()

TabRow(
    // Our selected tab is our current page
    selectedTabIndex = pagerState.currentPage,
    // Override the indicator, using the provided pagerTabIndicatorOffset modifier
    indicator = { tabPositions ->
        TabRowDefaults.Indicator(
            Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
        )
    }
) {
    // Add tabs for all of our pages
    pages.forEachIndexed { index, title ->
        Tab(
            text = { Text(title) },
            selected = pagerState.currentPage == index,
            onClick = { /* TODO */ },
        )
    }
}

HorizontalPager(
    count = pages.size,
    state = pagerState,
) { page ->
    // TODO: page content
}

2.自定义Tab指示器动画

根据官网的示例代码稍微修改下运行:

image.png

然后想把这个指示器设为圆角,不用它占满整个tab的宽度。

貌似怎么去配置修改这个TabRowDefaults.Indicator的Modifier都达不到想要的效果。

那就只能自定义TabIndicator了。 开始提出自定义需求:

  • 指示器为圆角
  • 指示器可以更具percent占满tab的宽度
  • 指示器的动画要和下面的HorizontalPager的滑动保持一致,不能等下面的pager滑动动画完了再做动画。
/**
 * PagerTap 指示器
 * @param  percent  指示器占用整个tab宽度的比例
 * @param  height   指示器的高度
 * @param  color    指示器的颜色
 */
@OptIn(ExperimentalPagerApi::class)
@Composable
fun PagerTabIndicator(
    tabPositions: List<TabPosition>,
    pagerState: PagerState,
    color: Color = MaterialTheme.colors.primarySurface,
    @FloatRange(from = 0.0, to = 1.0) percent: Float = 0.4f,
    height: Dp = 4.dp,
) {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val currentPage = minOf(tabPositions.lastIndex, pagerState.currentPage)
        val currentTab = tabPositions[currentPage]
        val previousTab = tabPositions.getOrNull(currentPage - 1)
        val nextTab = tabPositions.getOrNull(currentPage + 1)
        val fraction = pagerState.currentPageOffset

        val indicatorWidth = currentTab.width.toPx() * percent

        val indicatorOffset = if (fraction > 0 && nextTab != null) {
            lerp(currentTab.left, nextTab.left, fraction).toPx()
        } else if (fraction < 0 && previousTab != null) {
            lerp(currentTab.left, previousTab.left, -fraction).toPx()
        } else {
            currentTab.left.toPx()
        }

        /*Log.i(
            "hj",
            "fraction = ${fraction} , indicatorOffset = ${indicatorOffset}"
        )*/
        val canvasHeight = size.height
        drawRoundRect(
            color = color,
            topLeft = Offset(
                indicatorOffset + (currentTab.width.toPx() * (1 - percent) / 2),
                canvasHeight - height.toPx()
            ),
            size = Size(indicatorWidth + indicatorWidth * abs(fraction), height.toPx()),
            cornerRadius = CornerRadius(50f)
        )
    }
}

这里上面indicatorOffset 就是pager的滑动的指示器横向的偏移量,它会跟随pager的滑动不断变化,进而对这个comopse进行重组,所以大家在这里看不到任何animation的api。

indicatorOffset这里的代码我之直接copy Modifier.pagerTabIndicatorOffset方法里面的。

这里用了Canvas,没用Box 、Driver,这种圆角线能用Canvas实现就用Canvas去实现,因为性能比Box等更好一些,这在jectpack compose 官网有讲到:

image.png

image.png

3. 自定义Tab

官网示例代码中的tab文字任何动画都没有,只看到字体大小和颜色在滑动的过程中瞬间改变。

提出自定义tab的需求:

  • Tab字体大小随着pager的滑动和动画而改变。
  • Tab字体的weight随着pager的滑动和动画而改变。
  • Tab字体的颜色随着pager的滑动和动画而渐变。

这里我们就要得到每个tab对应滑动的progress 它的值0f到1f,0f表示该tab未选中的状态,1f表示该tab选中的状态,然后就要根据progress得到对应的fontSize,fontWeight,color了。

/**
 * 自定义 PagerTab
 * @param index                     对应第几个tab 从0开始
 * @param pageCount                 page的总个数
 * @param selectedContentColor      tab选中时的颜色
 * @param unselectedContentColor    tab没选中时的颜色
 * @param selectedFontSize          tab选中时的文字大小
 * @param unselectedFontSize        tab没选中时的文字大小
 * @param selectedFontWeight        tab选中时的文字比重
 * @param unselectedFontWeight      tab没选中时的文字比重
 */
@OptIn(ExperimentalPagerApi::class)
@Composable
fun PagerTab(
    modifier: Modifier = Modifier,
    pagerState: PagerState,
    index: Int,
    pageCount: Int,
    text: String,
    selectedContentColor: Color = MaterialTheme.colors.primary,
    unselectedContentColor: Color = MaterialTheme.colors.onSurface,
    selectedFontSize: TextUnit = 18.sp,
    unselectedFontSize: TextUnit = 15.sp,
    selectedFontWeight: FontWeight = FontWeight.Bold,
    unselectedFontWeight: FontWeight = FontWeight.Normal,
) {
    val previousIndex = max(index - 1, 0)
    val nextIndex = min(index + 1, pageCount - 1)
    val currentIndexPlusOffset = pagerState.currentPage + pagerState.currentPageOffset

    val progress =
        if (currentIndexPlusOffset >= previousIndex && currentIndexPlusOffset <= nextIndex) {
            1f - abs(index - currentIndexPlusOffset)
        } else {
            0f
        }

    val fontSize = androidx.compose.ui.unit.lerp(unselectedFontSize, selectedFontSize, progress)
    val fontWeight =
        androidx.compose.ui.text.font.lerp(unselectedFontWeight, selectedFontWeight, progress)
    val color =
        androidx.compose.ui.graphics.lerp(unselectedContentColor, selectedContentColor, progress)

    Box(
        modifier = modifier,
        contentAlignment = Alignment.Center
    ) {
        Text(text = text, color = color, fontSize = fontSize, fontWeight = fontWeight)
    }
}

大家可以打印看currentIndexPlusOffset ,当从第一页滑动到第二页 ,它是从0f到1f来改变,第二页滑动到第一页就变成了从1f到0f,第二页滑动到第三页,它就是从1f到2f,以此类推;通过它就可以判断到底哪两个页面的tab需要做动画了 if (currentIndexPlusOffset >= previousIndex && currentIndexPlusOffset <= nextIndex)

然后就可以通过currentIndexPlusOffset去得到每个tab的progress了。

最后是官方都有api 去根据fraction(progress)得到对应value的方法。

val fontSize = androidx.compose.ui.unit.lerp(unselectedFontSize, selectedFontSize, progress)
val fontWeight =
    androidx.compose.ui.text.font.lerp(unselectedFontWeight, selectedFontWeight, progress)
val color =
    androidx.compose.ui.graphics.lerp(unselectedContentColor, selectedContentColor, progress)

4. 最后的使用的代码

val pages = arrayOf("Home", "Shows", "Books")

@OptIn(ExperimentalPagerApi::class)
@Composable
fun PagerScreen() {
    Scaffold(topBar = {
        CenterTopAppBar(title ="Pager")
    }) {
        Column(Modifier.padding(it)) {
            val pagerState = rememberPagerState()
            TabRow(
                modifier = Modifier.fillMaxWidth(),
                // Our selected tab is our current page
                selectedTabIndex = pagerState.currentPage,
                // Override the indicator, using the provided pagerTabIndicatorOffset modifier
                indicator = { tabPositions ->
                    PagerTabIndicator(tabPositions = tabPositions, pagerState = pagerState)
                },
                backgroundColor = MaterialTheme.colors.surface
            ) {
                val scope: CoroutineScope = rememberCoroutineScope()
                // Add tabs for all of our pages
                pages.forEachIndexed { index, title ->
                    PagerTab(pagerState = pagerState,
                        index = index,
                        pageCount = pages.size,
                        text = title,
                        modifier = Modifier
                            .height(50.dp)
                            .clickable {
                                scope.launch {
                                    pagerState.animateScrollToPage(index)
                                }
                            })
                }
            }

            HorizontalPager(
                count = pages.size,
                state = pagerState,
            ) { page ->
                PagerContent(page)
            }
        }
    }
}

5. 最后效果

cf6t4-38x9o.gif