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
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) { | |
} |
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() | |
} |
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"), | |
), | |
) |
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()) } | |
) | |
} | |
} | |
} |
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) | |
} | |
} | |
} |
Using TabSync Compose:
The library’s dependency that I’m using can be found here:
GitHub – Ahmad-Hamwi/TabSync: A lightweight synchronizer between Android’s Tabs and Lists…
Add the maven central repository in root build.gradle:
allprojects { | |
repositories { | |
... | |
mavenCentral() | |
} | |
} |
Add dependency of the synchronizer library in your app’s build.gradle:
dependencies { | |
implementation 'io.github.ahmad-hamwi:tabsync-compose:1.0.0' | |
} |
Job Offers
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]) | |
} | |
} | |
} |
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