Search code examples
androidandroid-jetpack-composeandroid-tabs

Avoid initial scrolling when using Jetpack Compose ScrollableTabRow


I'm using a ScrollableTabRow to display some 60 Tabs. At the very beginning, the indicator should start "in the middle". However, this results in an unwanted scrolling animation when the composable is drawn - see video. Am i doing something wrong or is this component buggy?

enter image description here

@Composable
@Preview
fun MinimalTabExample() {
    val tabCount = 60
    var selectedTabIndex by remember { mutableStateOf(tabCount / 2) }

    ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
        repeat(tabCount) { tabNumber ->
            Tab(
                selected = selectedTabIndex == tabNumber,
                onClick = { selectedTabIndex = tabNumber },
                text = { Text(text = "Tab #$tabNumber") }
            )
        }
    }
}

But why would you like to do that? I'm writing a calendar-like application and have a day-detail-view. From there want a fast way to navigate to adjacent days. A Month into the future and a month into the past - relative to the selected month - is what i'm aiming for.


Solution

  • No, you are not doing it wrong. Also the component is not really buggy, rather the behaviour you are seeing is an implementation detail.

    If we check the implementation of the ScrollableTabRow composable we see that the selectedTabIndex is used in two places inside the composable:

    1. inside the default indicator implementation
    2. as an input parameter for the scrollableTableData.onLaidOut call

    The #1 is used for positioning the tabs inside the layout, so it is not interesting for this issue.

    The #2 is used to scroll to the selected tab index. The code below shows how the initial scroll state is set up, followed by the call to scrollableTabData.onLaidOut

    val scrollState = rememberScrollState()
    val coroutineScope = rememberCoroutineScope()
    val scrollableTabData = remember(scrollState, coroutineScope) {
        ScrollableTabData(
            scrollState = scrollState,
            coroutineScope = coroutineScope
        )
    }
    
    // ...
    
    scrollableTabData.onLaidOut(
        density = this@SubcomposeLayout,
        edgeOffset = padding,
        tabPositions = tabPositions,
        selectedTab = selectedTabIndex // <-- selectedTabIndex is passed here
    )
    

    And this is the implementation of the above call

        fun onLaidOut(
            density: Density,
            edgeOffset: Int,
            tabPositions: List<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( // <-- even the initial scroll is done using an animation
                                calculatedOffset,
                                animationSpec = ScrollableTabRowScrollSpec
                            )
                        }
                    }
                }
            }
        }
    

    As we can see already from the first comment

    Animate if the new tab is different from the old tab, or this is called for the first time

    but also in the implementation, even the first time the scroll offset is set using an animation.

    coroutineScope.launch {
        scrollState.animateScrollTo(
            calculatedOffset,
            animationSpec = ScrollableTabRowScrollSpec
        )
    }
    

    And the ScrollableTabRow class does not expose a way to control this behaviour.