Compose Tab 指示器`indicator`自定义,Tab和Pager联动效果

713 阅读3分钟

Compose Tab 指示器indicator自定义,Tab和Pager联动效果

简单改动indicator宽度

默认写法,可以设置高度、颜色,默认indicator 宽度充满

indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->
    TabRowDefaults.Indicator(
        Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
    )
}

自定义写法仿照默认写法加以改动,调整指示器宽度

indicator = {
    var currentTabPosition = it[index]
    var minIndicatorWidth = 50.dp
    val currentTabWidth by animateDpAsState(
        targetValue = minIndicatorWidth,
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
        label = ""
    )
    val indicatorOffset by animateDpAsState(
        targetValue = currentTabPosition.left.plus(
            currentTabPosition.width.minus(
                minIndicatorWidth
            ).div(2)
        ),
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
        label = ""
    )
    TabRowDefaults.Indicator(
        Modifier
            .fillMaxWidth()
            .wrapContentSize(Alignment.BottomStart)
            .offset(x = indicatorOffset)
            .width(currentTabWidth)
    )

}

Screenshot_20240112_184139.png


圆角背景指示器indicator,Tab和Pager联动效果,过程中的包名替换成自己的包名

用到的compose库中的源码如下

fun Modifier.tabIndicatorOffset(
    currentTabPosition: com.ex.hellofigmatwo.TabPosition
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "tabIndicatorOffset"
        value = currentTabPosition
    }
) {
    val currentTabWidth by animateDpAsState(
        targetValue = currentTabPosition.width,
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = ""
    )
    val indicatorOffset by animateDpAsState(
        targetValue = currentTabPosition.left,
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = ""
    )
    fillMaxWidth()
        .wrapContentSize(Alignment.CenterStart)
        .offset(x = indicatorOffset)
        .width(currentTabWidth)
}
private class ScrollableTabData(
    private val scrollState: ScrollState,
    private val coroutineScope: CoroutineScope
) {
    private var selectedTab: Int? = null

    fun onLaidOut(
        density: Density,
        edgeOffset: Int,
        tabPositions: List<com.ex.hellofigmatwo.TabPosition>,
        selectedTab: Int
    ) {
        // Animate if the new tab is different from the old tab, or this is called for the first
        // time (i.e selectedTab is `null`).
        if (this.selectedTab != selectedTab) {
            this.selectedTab = selectedTab
            tabPositions.getOrNull(selectedTab)?.let {
                // Scrolls to the tab with [tabPosition], trying to place it in the center of the
                // screen or as close to the center as possible.
                val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
                if (scrollState.value != calculatedOffset) {
                    coroutineScope.launch {
                        scrollState.animateScrollTo(
                            calculatedOffset,
                            animationSpec = tween(
                                durationMillis = 250,
                                easing = FastOutSlowInEasing
                            )
                        )
                    }
                }
            }
        }
    }

    /**
     * @return the offset required to horizontally center the tab inside this TabRow.
     * If the tab is at the start / end, and there is not enough space to fully centre the tab, this
     * will just clamp to the min / max position given the max width.
     */
    private fun com.ex.hellofigmatwo.TabPosition.calculateTabOffset(
        density: Density,
        edgeOffset: Int,
        tabPositions: List<com.ex.hellofigmatwo.TabPosition>
    ): Int = with(density) {
        val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset
        val visibleWidth = totalTabRowWidth - scrollState.maxValue
        val tabOffset = left.roundToPx()
        val scrollerCenter = visibleWidth / 2
        val tabWidth = width.roundToPx()
        val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2)
        // How much space we have to scroll. If the visible width is <= to the total width, then
        // we have no space to scroll as everything is always visible.
        val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0)
        return centeredTabOffset.coerceIn(0, availableSpace)
    }
}

private enum class TabSlots {
    Tabs,
    Indicator
}

class TabPosition internal constructor(val left: Dp, val width: Dp) {
    val right: Dp get() = left + width

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is TabPosition) return false

        if (left != other.left) return false
        if (width != other.width) return false

        return true
    }

    override fun hashCode(): Int {
        var result = left.hashCode()
        result = 31 * result + width.hashCode()
        return result
    }

    override fun toString(): String {
        return "TabPosition(left=$left, right=$right, width=$width)"
    }
}

按照比重填充形式的TabRow自定义

@Composable
fun NioTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    containerColor: Color = TabRowDefaults.containerColor,
    contentColor: Color = TabRowDefaults.contentColor,
    indicatorColor: Color = TabRowDefaults.contentColor,
    tabs: @Composable () -> Unit
) {
    Surface(
        modifier = modifier.selectableGroup(),
        color = containerColor,
        contentColor = contentColor
    ) {
        SubcomposeLayout(Modifier.fillMaxWidth()) { constraints ->
            val tabRowWidth = constraints.minWidth
            val tabMeasurables = subcompose(com.ex.hellofigmatwo.TabSlots.Tabs, tabs)
            val tabCount = tabMeasurables.size
            val tabWidth = (tabRowWidth / tabCount)
            val tabRowHeight = tabMeasurables.fold(initial = 0) { max, curr ->
                maxOf(curr.maxIntrinsicHeight(tabWidth), max)
            }
            val tabPlaceables = tabMeasurables.map {
                it.measure(
                    constraints.copy(
                        minWidth = tabWidth,
                        maxWidth = tabWidth,
                        minHeight = tabRowHeight
                    )
                )
            }
            val tabPositions = List(tabCount) { index ->
                TabPosition(tabWidth.toDp() * index, tabWidth.toDp())
            }
            layout(tabRowWidth, tabRowHeight) {
                subcompose(com.ex.hellofigmatwo.TabSlots.Indicator) {
                    Box(
                        Modifier
                            .tabIndicatorOffset(tabPositions[selectedTabIndex])
                            .fillMaxSize()
                            .background(
                                color = indicatorColor,
                                RoundedCornerShape(tabRowHeight.div(2))
                            )
                    )
                }.forEach {
                    it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(0, 0)
                }
                tabPlaceables.forEachIndexed { index, placeable ->
                    placeable.placeRelative(index * tabWidth, 0)
                }
            }
        }
    }
}

引用方法以及呈现效果如下,调整高度、圆角、背景

val titles = listOf("客户分类", "客户分析")//, , "Tab Pager 3","Tab Pager 4", "Tab Pager 5"
var tabLayoutWidth = LocalConfiguration.current.screenWidthDp.times(2 / 3f)
var pageState = rememberPagerState()
var scope = rememberCoroutineScope()
Column {
    NioTabRow(
        selectedTabIndex = pageState.currentPage,
        containerColor = Color.Transparent,
        indicatorColor = MaterialTheme.colorScheme.primary, modifier = Modifier
            .width(tabLayoutWidth.dp)
            .wrapContentHeight()
            .align(Alignment.CenterHorizontally)
            .background(
                Color.LightGray,
                RoundedCornerShape(25.dp)
            )
    ) {
        titles.forEachIndexed { index, title ->

            LeadingIconTab(
                selected = pageState.currentPage == index,
                onClick = {
                    scope.launch {
                        pageState.scrollToPage(index, 0f)
                    }
                },
                text = {
                    Text(
                        text = title, fontSize = TextUnit(13f, TextUnitType.Sp)
                    )
                }, icon = {
                    Icon(
                        imageVector = Icons.Default.Face,
                        contentDescription = "",
                        modifier = Modifier.size(16.dp)
                    )
                },
                selectedContentColor = Color.White,
                unselectedContentColor = Color.DarkGray, modifier = Modifier.height(40.dp)
            )
        }
    }
    val colors = listOf<Color>(
        Color(0xFF807532),
        Color(0xFFf8facd)
    )
    HorizontalPager(state = pageState, pageCount = titles.size) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(colors[it])
        ) {
            Text(
                modifier = Modifier.align(Alignment.Center),
                text = "Primary tab ${pageState.currentPage + 1} selected",
                style = MaterialTheme.typography.bodyLarge
            )
        }

    }
}

Screen_recording_20240112_174454 00_00_00-00_00_30.gif

可横向滑动的ScrollableTabRow自定义

@Composable
fun NioScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    containerColor: Color = TabRowDefaults.containerColor,
    contentColor: Color = TabRowDefaults.contentColor,
    indicatorColor: Color = TabRowDefaults.contentColor,
    edgePadding: Dp = 52.dp,
    tabs: @Composable () -> Unit
) {
    Surface(
        modifier = modifier,
        color = containerColor,
        contentColor = contentColor
    ) {
        val scrollState = rememberScrollState()
        val coroutineScope = rememberCoroutineScope()
        val scrollableTabData = remember(scrollState, coroutineScope) {
            ScrollableTabData(
                scrollState = scrollState,
                coroutineScope = coroutineScope
            )
        }
        SubcomposeLayout(
            Modifier
                .fillMaxWidth()
                .wrapContentSize(align = Alignment.CenterStart)
                .horizontalScroll(scrollState)
                .selectableGroup()
                .clipToBounds()
        ) { constraints ->
            val minTabWidth = 90.dp.roundToPx()
            val padding = edgePadding.roundToPx()

            val tabMeasurables = subcompose(com.ex.hellofigmatwo.TabSlots.Tabs, tabs)

            val layoutHeight = tabMeasurables.fold(initial = 0) { curr, measurable ->
                maxOf(curr, measurable.maxIntrinsicHeight(Constraints.Infinity))
            }

            val tabConstraints = constraints.copy(minWidth = minTabWidth, minHeight = layoutHeight)
            val tabPlaceables = tabMeasurables
                .map { it.measure(tabConstraints) }

            val layoutWidth = tabPlaceables.fold(initial = padding * 2) { curr, measurable ->
                curr + measurable.width
            }

            // Position the children.
            layout(layoutWidth, layoutHeight) {
                // Place the tabs
                val tabPositions = mutableListOf<com.ex.hellofigmatwo.TabPosition>()
                var left = padding
                tabPlaceables.forEach {
                    tabPositions.add(
                        com.ex.hellofigmatwo.TabPosition(
                            left = left.toDp(),
                            width = it.width.toDp()
                        )
                    )
                    left += it.width
                }
                // The indicator container is measured to fill the entire space occupied by the tab
                // row, and then placed on top of the divider.
                subcompose(com.ex.hellofigmatwo.TabSlots.Indicator) {
                    var tp = tabPositions[selectedTabIndex]
                    Box(
                        Modifier
                            .tabIndicatorOffset(tp)
                            .fillMaxSize()
                            .background(
                                color = indicatorColor,
                                RoundedCornerShape(layoutHeight.div(2))
                            )
                    )

                }.forEach {
                    it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0)
                }
                tabPositions.forEachIndexed { index, tabPosition ->
                    tabPlaceables[index].placeRelative(tabPosition.left.roundToPx(), 0)
                }
                scrollableTabData.onLaidOut(
                    density = this@SubcomposeLayout,
                    edgeOffset = padding,
                    tabPositions = tabPositions,
                    selectedTab = selectedTabIndex
                )
            }
        }
    }
}

引用方法以及呈现效果如下,调整左右边距、高度、圆角、背景、横向超出一屏滑动

val titles = listOf(
    "Tab Pager 1",
    "Tab Pager 2",
    "Tab Pager 3",
    "Tab Pager 4",
    "Tab Pager 5",
    "Tab Pager 6"
)
var tabLayoutWidth = LocalConfiguration.current.screenWidthDp.times(2 / 3f)
var pageState = rememberPagerState()
var scope = rememberCoroutineScope()
Column {
    NioScrollableTabRow(
        selectedTabIndex = pageState.currentPage,
        containerColor = Color.Transparent,
        indicatorColor = MaterialTheme.colorScheme.primary, modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .background(
                Color.LightGray
            ), edgePadding = 12.dp
    ) {
        titles.forEachIndexed { index, title ->

            Tab(
                selected = pageState.currentPage == index,
                onClick = {
                    scope.launch {
                        pageState.scrollToPage(index, 0f)
                    }
                },
                text = {
                    Text(
                        text = title, fontSize = TextUnit(13f, TextUnitType.Sp)
                    )
                },
                selectedContentColor = Color.White,
                unselectedContentColor = Color.DarkGray, modifier = Modifier.height(40.dp)
            )
        }
    }
    val colors = listOf<Color>(
        Color(0xFF807532),
        Color(0xFFf8facd),
        Color(0xFFfd5e53),
        Color(0xFFcec7a7),
        Color(0xFFaec6cf), Color(0xFF2C5364)
    )
    HorizontalPager(state = pageState, pageCount = titles.size) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(colors[it])
        ) {
            Text(
                modifier = Modifier.align(Alignment.Center),
                text = "Primary tab ${pageState.currentPage + 1} selected",
                style = MaterialTheme.typography.bodyLarge
            )
        }

    }
}

Screen_recording_20240112_174317 00_00_00-00_00_30.gif

总结,先看组件有没有需要的效果,没有就点击进入源码中,将源码关联代码复制出来然后根据需要改造 需要学习更多组件用法请查看 Compose material3