Blog Infos
Author
Published
Topics
,
Published
Add synchronized scrolling in your Android Jetpack Compose app

 

I had an article that has been demonstrating how you could synchronize Android’s RecyclerView with TabLayout using a library that I wrote called TabSync, its main purpose was to encapsulate the boilerplate code that you had to write when you synchronize RecyclerView with TabLayout. You can read that article here:

Synchronize RecyclerView with TabLayout using TabSync

This article is all about Jetpack Compose! We’ll be using the Jetpack Compose version of the library, and we’ll dig deep into how it’s working.

The library is meant to synchronize LazyListState-based composables (like LazyColumn or LazyRow) with some index-based composables (like ScrollableTabRow).

I’ll repeat my previous example: when you’re trying to show a list of categories, each category has its own body, and that body has to be synchronized with a tab of a Tab bar. This has been implemented in many today’s apps like Telegram in their emoji’s section for example.

Telegram’s emoji section synchronization

Telegram’s emoji section synchronization

The expected behavior from the synchronization is that as you scroll through your list’s items, the corresponding tab will be selected automatically, and vice-versa; when pressing on a tab, the list should scroll to the corresponding item.

Let’s see how we can implement this using Jetpack Compose and the library TabSync Compose.
Prerequisites:
  • Familiarity with Jetpack Compose.
  • Familiarity with LazyListState-based composables LazyList or LazyColumn, and index-based composables like ScrollableTabRow.
Setup:
1- Make some models in order to display them as items in a LazyColumn:

We’ll be making an Item class that will have a simple String field:

class Item(val content: String) {
}
view raw Item.kt hosted with ❤ by GitHub

And a Category class that will be nesting a list of items:

class Category(val name: String, vararg item: Item) {
val listOfItems: List<Item> = item.toList()
}
view raw Category.kt hosted with ❤ by GitHub

And therefore, our example list is going to be initialized like this:

private val categories = mutableListOf(
Category(
"Category 1",
Item("Item 1"),
Item("Item 2"),
Item("Item 3"),
Item("Item 4"),
Item("Item 5"),
Item("Item 6")
),
...
...
...
Category(
"Category 5",
Item("Item 1"),
Item("Item 2"),
Item("Item 4"),
Item("Item 5"),
),
)
view raw MainActivity.kt hosted with ❤ by GitHub

And now we have a list of categories, each category has a list of items.
This will be passed to our composables.

2- Create your UI using LazyColumn and ScrollableTabRow:

We need a Tab bar, a List which renders categories, and each category can contain its items.
Here I’m creating the tab bar, which takes in a list of categories to render the tabs, a currently selected tab index, and a tab click callback:

@Composable
fun MyTabBar(
categories: List<Category>,
selectedTabIndex: Int,
onTabClicked: (index: Int, category: Category) -> Unit
) {
ScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp
) {
categories.forEachIndexed { index, category ->
Tab(
selected = index == selectedTabIndex,
onClick = { onTabClicked(index, category) },
text = { Text(category.name.uppercase()) }
)
}
}
}
view raw MyTabBar.kt hosted with ❤ by GitHub

Here I’m creating a List which takes in categories, and a LazyListState.
Each item in the list represents a category. A category will contain a list of items. (Full code in the repo)

@Composable
fun MyLazyList(
categories: List<Category>,
listState: LazyListState = rememberLazyListState(),
) {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(categories) { _, category ->
ItemCategory(category)
}
}
}
view raw MyLazyList.kt hosted with ❤ by GitHub
Using TabSync Compose:

The library’s dependency that I’m using can be found here:

allprojects {
repositories {
...
mavenCentral()
}
}
view raw build.gradle hosted with ❤ by GitHub

Add dependency of the synchronizer library in your app’s build.gradle:

dependencies {
implementation 'io.github.ahmad-hamwi:tabsync-compose:1.0.0'
}
view raw buidl.gradle hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Improving LazyColumn performance

Do you ever look at a LazyColumn and wonder: what is going on inside? Under a simple API surface, you’ll see arguably the most complex component in the Compose UI.
Watch Video

Improving LazyColumn performance

Andrei Shikov
Compose
Google

Improving LazyColumn performance

Andrei Shikov
Compose
Google

Improving LazyColumn performance

Andrei Shikov
Compose
Google

Jobs

Call lazyListTabSync composable function:

This function will provide you with a currently selected index, a currently selected index setter, and a lazy list state, and using them together with out composables would look like this:

@Composable
fun TabSyncComposeScreen(categories: List<Category>) {
val (selectedTabIndex, setSelectedTabIndex, lazyListState) = lazyListTabSync(categories.indices.toList())
Column {
MyTabBar(
categories = categories,
selectedTabIndex = selectedTabIndex,
onTabClicked = { index, _ -> setSelectedTabIndex(index) }
)
MyLazyList(categories, lazyListState)
}
}
And the results are:

 

TabSync Compose in action!

 

Parameters overview:
@Composable
fun TabSyncComposeScreen(categories: List<Category>) {
val (selectedTabIndex, setSelectedTabIndex, lazyListState) = tabSyncMediator(
mutableListOf(0, 2, 4), //Mandatory. The indices of lazy list items to sync the tabs with
tabsCount = 3, //Optional. To check for viability of the synchronization with the tabs. Optimal when equals the count of syncedIndices.
lazyListState = rememberLazyListState(), //Optional. To provide your own LazyListState. Defaults to rememberLazyListState().
smoothScroll = true, // Optional. To make the auto scroll smooth or not when clicking tabs. Defaults to true
)
Column {
MyTabBar(
categories = categories,
selectedTabIndex = selectedTabIndex,
onTabClicked = { index, _ -> setSelectedTabIndex(index) }
)
MyLazyList(categories, lazyListState)
}
}

Results with smooth scroll on:

 

TabSync with smooth scroll flagged

 

And that’s it! It’s as simple as that! Now for the nerdy part:

Digging into the library’s code
How the synchronization is implemented?

We need a way to reflect the state of one the states off of the other, this means that whatever the value of the currently selected index, it should reflect the correct position in the lazy list state, and vice-versa, whatever was the current position of the lazy list state, it should reflect the correct index.

This is implemented in two areas in the library:

1- The manual mutation of the currently selected item should reflect a scroll to the corresponding item:

A tab click for example should corresponds to a scroll to the correct item, in this case, the setter function should be called. When it’s called, I’m launching a coroutine scope to scroll to the corresponding item like so:

operator fun component2(): (Int) -> Unit = {
...
coroutineScope.launch {
if (smoothScroll) {
lazyListState.animateScrollToItem(syncedIndices[selectedTabIndex])
} else {
lazyListState.scrollToItem(syncedIndices[selectedTabIndex])
}
}
}
view raw TabSyncState.kt hosted with ❤ by GitHub
2- Listening to the lazyListState scroll changes, and update the currently selected index accordingly

This is done by listening to the change of the LayoutInfo object inside of lazyListState that gets mutated each time there’s a scroll.

I’m converting it into a flow and on each emission, I’m determining the index that will be “selected” by looking for the first fully or partially visible item position, and the last fully visible item position. If there’s none, then I do nothing.

If the item is found and is inside of the list of desired indices to sync with, and not yet selected, I choose it to be selected.

@Composable
fun lazyListTabSync(...): TabSyncState {
...
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.layoutInfo }.collect {
var itemPosition = lazyListState.findFirstFullyVisibleItemIndex()
if (itemPosition == -1) {
itemPosition = lazyListState.firstVisibleItemIndex
}
if (itemPosition == -1) {
return@collect
}
if (lazyListState.findLastFullyVisibleItemIndex() == syncedIndices.last()) {
itemPosition = syncedIndices.last()
}
if (syncedIndices.contains(itemPosition) && itemPosition != syncedIndices[selectedTabIndex]) {
selectedTabIndex = syncedIndices.indexOf(itemPosition)
}
}
}
...
}

The LayoutInfo object that I’m listening to provided some low level information about the currently visible items in the lists, so I’m using some extension functions to determine the “fully” visible items like so:

fun LazyListState.findFirstFullyVisibleItemIndex(): Int = findFullyVisibleItemIndex(false)
fun LazyListState.findLastFullyVisibleItemIndex(): Int = findFullyVisibleItemIndex(true)
fun LazyListState.findFullyVisibleItemIndex(reversed: Boolean): Int {
layoutInfo.visibleItemsInfo
.run { if (reversed) reversed() else this }
.forEach { itemInfo ->
val itemStartOffset = itemInfo.offset
val itemEndOffset = itemInfo.offset + itemInfo.size
val viewportStartOffset = layoutInfo.viewportStartOffset
val viewportEndOffset = layoutInfo.viewportEndOffset
if (itemStartOffset >= viewportStartOffset && itemEndOffset <= viewportEndOffset) {
return itemInfo.index
}
}
return -1
}

Check out the GitHub repository of the library for the full code of the example and the source code, and feel free to contribute to the library to add or edit any certain feature!

 

 

Thank you for reading, and happy coding!

 

This article was previously published on proandroiddev.com

 

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu