Infinite auto-scrolling of lists with RecyclerView and LazyLists in Compose

Exploring different approaches to creating endless autoscrolling lists on Android

Infinite scrolling of lists with RecyclerView (left) and LazyRow (right)
Infinite scrolling of lists with RecyclerView (left) and LazyRow (right)

RecyclerView + ListAdapter implementation

RecyclerView is a really cool and powerful tool for displaying list (s) of content on Android. There are tons of great articles and examples out there on various RecyclerView solutions, so we won’t cover them here. The main focus will be on creating endless lists with automatic scrolling.

Made with love ️ using RecyclerView (GIF reduced frame rate)
Made with love ️ using RecyclerView (GIF reduced frame rate)

How can we solve this problem?

One thing that comes to mind is to create a list of items that are repeated so many times that you can think of it as endless. While this solution works fine, it’s a bit wasteful, but we can try to do better, right?

Let’s go directly to the code to configure FeaturesAdapterwhich implements ListAdapter

data class Feature(
    @DrawableRes val iconResource: Int,
    val contentDescription: String,
)
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
}

An adapter that implements a ListAdapter that computes the differences between lists as they are updated.

Why ListAdapter?

RecyclerView.Adapter is the base class for representing list data in RecyclerView, including calculating differences between lists on a background thread. This class is a convenient wrapper around AsyncListDifferwhich implements the generic default adapter behavior for item access and counting.

But why does comparison (diff, diffing) matter when we just want to show several of the same items 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 parameter for a list of properties that can be supplied ViewModel… This list is passed to the adapter as an initial list, and the coroutine is started with a call autoScrollFeaturesList… This is the basic logic 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 figure out how to do it.

  1. Let’s start with what exists recursive a function that calls itself as the RecyclerView is forced to scroll through the list endlessly.

  2. The RecyclerView is scrolled by 5 pixels, if it can be scrolled horizontally, it means that we haven’t reached the end of the list yet.

  3. If the RecyclerView can no longer scroll, it means that it has reached the end of the list, now we will split the existing list into two parts:
    – The first part starts from the first visible item in the list to the last.
    – The second part starts from the first item of the existing list to the first visible item (not inclusive).

  4. The new list is passed to the adapter. This is where the diff, diffing technology of lists in the adapter comes in handy. The adapter detects that the visible part of the list is the same as the first part of the new list, so there is no visual update in the RecyclerView at this point, and the list items are now on the right.

  5. Then step 2 fires again, scrolling the list 5px.

This is a pause function, so the coroutine will be canceled when the scope disappears, and we don’t need to worry about explicitly terminating it.

A rough visual representation of steps 3 and 4.
A rough visual representation of steps 3 and 4.

This GIF demonstrates the process of sending a new list to the adapter. If it’s like handling views, it’s because it looked like the original GIF that was edited to create this … well, let’s move on.

… … …

Now with Compose

  Made with love ️ using Compose (GIF reduced frame rate)
Made with love ️ using Compose (GIF reduced frame rate)

LazyList is an internal implementation for displaying lists in Compose. In this post, we will use LazyRowwhich is perfect for our horizontally scrolling list.

Let’s write composable FeatureTile and FeatureList

@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 – analog of 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

FeatureList with auto scrolling

What’s really going on here?

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

… when the state of your application changes, Jetpack Compose schedules a recomposition. Recomposition triggers compositing functions that may have changed in response to a change in state, and Jetpack Compose updates the changes in the composition to reflect them.State and Jetpack Compose

Let’s put everything on the shelves

  1. An object MutableState initialized with a list of functions provided by the linkable FeatureList… Thus, if the list is updated, the precast LazyRow will be relinked with the new list.

  2. items () is used to add a list of elements, and the last parameter is a lambda that defines the content of the element.

  3. When the last item is issued, itemsListState updated with a new list, similar to the approach RecyclerViewused above. Insofar as itemsListState checked by layout, and changing that state, you guessed it, plans a recomposition for LazyRow

  4. An interesting difference between LazyLists and RecyclerView (from ListAdapter) is that the scroll state is kept in LazyLists so that if the list is updated, the scroll state will not change. If the scroll state is at the end of the list, then when the list is refreshed, the scroll state will still remain at the end of the list. Therefore, we need to reset the scroll state before updating the list in order to achieve the desired effect. The scroll state is reset to the item at index 0 for the updated list, which is the first visible item in the current list, so we don’t see any visual changes.

  5. When FeaturesList enters the composition, the block is triggered LaunchedEffect and the initial call of the recursive function occurs autoScroll… Coroutine is canceled when composable FeaturesList leaves the composition.

  6. Eventually autoScroll scrolls forward the list with some delay between each scroll, similar to approach RecyclerView

… … …

Bonus: AutoScrollingLazyRow

Since the link function is successful, a generic implementation needs to be created AutoScrollingLazyRowthat 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)
}

Standard component AutoScrollingLazyRow

… … …

Final thoughts and tangential considerations

Using LaunchedEffect with the Unit key, only LazyRow, this is logical and expected behavior. However, if the key for LaunchedEffect installed in itemsListState, the Features List is also reflowed. LaunchedEffect restarts when the key changes, but since nothing else is in scope FeaturesList does not use itemsListState, it is important to pay attention to the fact that setting the wrong keys for LaunchedEffect may cause unwanted recomposition.

Infinite vertical auto-scrolling lists can also be created using a similar technique. A small nuance when using the Compose option is that user input is disabled for simplicity. This post took you through one approach to creating endless auto-scrolling lists, but Compose has many different ways to achieve this!

Links

RecyclerView

ListAdapter

Lists in Compose

condition

Side Effects in Compose

Jetpack Compose Effect Handlers

Tail recursive functions in Kotlin


Material prepared as part of the course “Android Developer. Professional”… We invite you to Open Day online, where you can learn more about the training format and program, as well as get to know the course instructor.

Similar Posts

Leave a Reply

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