Blog Infos
Author
Published
Topics
, , , ,
Published
RecyclerView + ListAdapter implementation

RecyclerView is a really cool and powerful tool to display list(s) of content on Android. There are tons of great posts and samples about different RecyclerView solutions so this post won’t cover that. The primary focus of this post is on creating infinite auto-scrolling lists.

How do we approach this problem?

One thing that comes to mind is to create a list of items that are repeated so many times, that we can almost fake it as an infinite list. Although a workable solution, it’s a bit wasteful, we can try to do better, can’t we? ?

Let’s get straight into the code to setup FeaturesAdapter which implements ListAdapter.

data class Feature(
@DrawableRes val iconResource: Int,
val contentDescription: String,
)
view raw Feature.kt hosted with ❤ by GitHub
class FeaturesAdapter : ListAdapter<Feature, RecyclerView.ViewHolder>(FeatureDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater
.from(parent.context)
.inflate(R.layout.item_feature_tile, parent, false)
return FeatureItemViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemViewHolder = holder as FeatureItemViewHolder
itemViewHolder.bind(getItem(position))
}
inner class FeatureItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(feature: Feature) {
with(itemView) {
imageFeature.setImageResource(feature.iconResource)
imageFeature.contentDescription = feature.contentDescription
}
}
}
}
class FeatureDiffCallback : DiffUtil.ItemCallback<Feature>() {
override fun areItemsTheSame(oldItem: Feature, newItem: Feature): Boolean =
oldItem.iconResource == newItem.iconResource
override fun areContentsTheSame(oldItem: Feature, newItem: Feature): Boolean =
oldItem == newItem
}
Why ListAdapter?

RecyclerView.Adapter base class for presenting List data in a RecyclerView, including computing diffs between Lists on a background thread. This class is a convenience wrapper around AsyncListDiffer that implements Adapter common default behavior for item access and counting.

But why does diffing matter when we just want to show the same few elements in a loop? Let’s dive into the code and see.

private fun setupFeatureTiles(featuresList: List<Features>) {
with(recyclerFeatures) {
layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
adapter = featuresAdapter
}
featuresAdapter.submitList(featuresList)
lifecycleScope.launch { autoScrollFeaturesList() }
}

The function has a param for the list of features which could be provided by the ViewModel. The list is submitted to the adapter as the initial list and a coroutine is launched with a call to autoScrollFeaturesList. This is the core logic right below.

private tailrec suspend fun autoScrollFeaturesList() {
if (recyclerFeatures.canScrollHorizontally(DIRECTION_RIGHT)) {
recyclerFeatures.smoothScrollBy(SCROLL_DX, 0)
} else {
val firstPosition =
(recyclerFeatures.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
if (firstPosition != RecyclerView.NO_POSITION) {
val currentList = featuresAdapter.currentList
val secondPart = currentList.subList(0, firstPosition)
val firstPart = currentList.subList(firstPosition, currentList.size)
featuresAdapter.submitList(firstPart + secondPart)
}
}
delay(DELAY_BETWEEN_SCROLL_MS)
autoScrollFeaturesList()
}
private const val DELAY_BETWEEN_SCROLL_MS = 25L
private const val SCROLL_DX = 5
private const val DIRECTION_RIGHT = 1
Let’s break it down, shall we?
  1. To begin with, there is a recursive function that calls itself as the RecyclerView has to scroll infinitely.
  2. RecyclerView scrolls by 5 pixels if it can scroll horizontally, that means it hasn’t reached the end of the list.
  3. If the RecyclerView cannot scroll anymore, that means it has reached the end of the list, now we split the existing list into two parts:
    – The first part starts from the first visible item in the list to the last item.
    – The second part starts from the first item of the existing list till the first visible item (not inclusive).
  4. The new list is submitted to the adapter. This is where the list adapter diffing comes in handy. The adapter figures out that the visible part of the list is the same as the first part of the new list, so there are no visual updates in the RecyclerView at that moment and now there are list items on the right.
  5. Then step 2 gets triggered again, scrolling by 5 pixels.

This is a suspend function so the coroutine will get cancelled as the scope is destroyed and we don’t have to worry about explicitly stopping it.

1_c5bLmSxVH368Rsr1BeL_MA
An approximate visual representation of step 3 and 4.

The GIF tries to explain visually the process of submitting the new list to the adapter. If it looks similar to recycling of views, it is because that was the source GIF that was edited to create this … well, let’s move on. ?

Now with Compose
1_FAYCkKijh6kDOdTNqmLJ0Q
Made with ❤️ using Compose (The GIF has reduced frame rate)

LazyList is an internal implementation for displaying lists in Compose. For this post, we will use LazyRow that is perfect for our horizontal scrolling list.

Let’s write FeatureTile and FeatureList Composables.

@Composable
fun FeatureTile(feature: Feature) {
Card(
shape = MaterialTheme.shapes.small,
modifier = Modifier
.size(Dimens.grid_6)
.aspectRatio(1f)
.padding(1.dp),
elevation = Dimens.plane_2
) {
Image(
painter = painterResource(id = feature.iconResource),
contentDescription = feature.contentDescription,
alignment = Alignment.Center,
modifier = Modifier.padding(Dimens.grid_1_5)
)
}
}

FeatureTile–analogous to FeaturesAdapter.kt

@Composable
fun FeatureList(
list: List<Feature>,
modifier: Modifier,
) {
var itemsListState by remember { mutableStateOf(list) }
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier,
) {
items(itemsListState) {
FeatureTile(feature = it)
Spacer(modifier = Modifier.width(Dimens.grid_1))
if (it == itemsListState.last()) {
val currentList = itemsListState
val secondPart = currentList.subList(0, lazyListState.firstVisibleItemIndex)
val firstPart = currentList.subList(lazyListState.firstVisibleItemIndex, currentList.size)
rememberCoroutineScope().launch {
lazyListState.scrollToItem(0, maxOf(0, lazyListState.firstVisibleItemScrollOffset - SCROLL_DX_INT))
}
itemsListState = firstPart + secondPart
}
}
}
LaunchedEffect(Unit) {
autoScroll(lazyListState)
}
}
private tailrec suspend fun autoScroll(lazyListState: LazyListState) {
lazyListState.scroll(MutatePriority.PreventUserInput) {
scrollBy(SCROLL_DX)
}
delay(DELAY_BETWEEN_SCROLL_MS)
autoScroll(lazyListState)
}
private const val DELAY_BETWEEN_SCROLL_MS = 8L
private const val SCROLL_DX = 1f
view raw FeatureList.kt hosted with ❤ by GitHub

FeatureTile–analogous to FeaturesAdapter.kt

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

What’s really happening here?

FeatureList takes in the list of features and shows them in a LazyRow. Here we take advantage of State.

… when the state of your app changes, Jetpack Compose schedules recomposition. Recomposition is running the composables that may have changed in response to state changes, and Jetpack Compose updates the composition to reflect any change. — State and Jetpack Compose

Let’s break it down like a fraction
  1. A MutableState object is initialized with the list of features provided to the FeatureList composable. So if the list is updated the LazyRow composable will recompose with the new list.
  2. items() is used to add a list of items and the last parameter is the lambda where the item content is defined.
  3. When the last item is emitted, itemsListState is updated with the new list, similar to the RecyclerView approach used above. Since itemsListState is observed by the composable, and a change to that, yes you guessed it, schedules a recomposition for LazyRow.
  4. An interesting difference between LazyLists and RecyclerView (with ListAdapter) is that the scroll state is saved with LazyLists in a way that if the list is updated, the scroll state would not change. If the scroll state is at the end of the list, on updating the list, the scroll state will still remain at the end of the list. So we need to reset the scroll state before updating the list to have the desired effect. The scroll state is reset to item with index 0 for the updated list, which is the first visible item in the current list, so we do not see any visual changes.
  5. When FeaturesList is entering the composition, the LaunchedEffectblock is triggered and the initial call to the recursive function autoScroll takes place. The coroutine is cancelled when FeaturesList composable leaves the composition.
  6. To end with, autoScroll scrolls the list forward with some delay between each scroll similar to the RecyclerView approach.
Bonus: AutoScrollingLazyRow

As composables compose so well, it makes sense to create a generic implementation of AutoScrollingLazyRow that is easy to use and reuse.

@Composable
fun <T : Any> AutoScrollingLazyRow(
list: List<T>,
modifier: Modifier = Modifier,
scrollDx: Float = SCROLL_DX,
delayBetweenScrollMs: Long = DELAY_BETWEEN_SCROLL_MS,
divider: @Composable () -> Unit = { Spacer(modifier = Modifier.width(Dimens.grid_1)) },
itemContent: @Composable (item: T) -> Unit,
) {
var itemsListState by remember { mutableStateOf(list) }
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier,
) {
items(itemsListState) {
itemContent(item = it)
divider()
if (it == itemsListState.last()) {
val currentList = itemsListState
val secondPart = currentList.subList(0, lazyListState.firstVisibleItemIndex)
val firstPart = currentList.subList(lazyListState.firstVisibleItemIndex, currentList.size)
rememberCoroutineScope().launch {
lazyListState.scrollToItem(0, maxOf(0, lazyListState.firstVisibleItemScrollOffset - scrollDx.toInt()))
}
itemsListState = firstPart + secondPart
}
}
}
LaunchedEffect(Unit) {
autoScroll(lazyListState, scrollDx, delayBetweenScrollMs)
}
}
private tailrec suspend fun autoScroll(
lazyListState: LazyListState,
scrollDx: Float,
delayBetweenScrollMs: Long,
) {
lazyListState.scroll(MutatePriority.PreventUserInput) {
scrollBy(scrollDx)
}
delay(delayBetweenScrollMs)
autoScroll(lazyListState, scrollDx, delayBetweenScrollMs)
}
private const val DELAY_BETWEEN_SCROLL_MS = 8L
private const val SCROLL_DX = 1f
AutoScrollingLazyRow(list = featuresList) {
FeatureTile(feature = it)
}

A generic AutoScrollingLazyRow component ?

Final thoughts & tangential observations

When using LaunchedEffect with key as Unit, only LazyRow is recomposed, that makes sense and is expected behavior. However, if the key for LaunchedEffect is set to itemsListState, Features List is also recomposed. LaunchedEffect re-launches when the key changes but since nothing else in the scope of FeaturesList uses itemsListState, it was interesting that setting incorrect keys for LaunchedEffect can cause unwanted recompositions.

Infinite auto-scrolling vertical lists can also be created using the same technique. A minor caveat with the Compose variant is that user input is disabled for simplicity. This post highlights a single approach for infinite auto-scrolling lists and there might be many diverse ways to achieve this in Compose!

References

 

That’s all folks! Feel free to

comment or message me if you have any questions, suggestions or ideas! ?

GitHub | LinkedIn | Twitter

Thanks to Chirag Kunder, Stojan Anastasov, and Mario Sanoguera de Lorenzo.

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
Hi, today I come to you with a quick tip on how to update…
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