Avoid initial scrolling when using Jetpack Compose ScrollableTabRow

1.4k Views Asked by At

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.

1

There are 1 best solutions below

2
Ma3x On BEST ANSWER

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.