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, | |
) |
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?
- To begin with, there is a recursive function that calls itself as the RecyclerView has to scroll infinitely.
- RecyclerView scrolls by 5 pixels if it can scroll horizontally, that means it hasn’t reached the end of the list.
- 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). - 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.
- 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.
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
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 |
FeatureTile–analogous to FeaturesAdapter.kt
Job Offers
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
- 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.
- items() is used to add a list of items and the last parameter is the lambda where the item content is defined.
- 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.
- 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.
- 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.
- 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
- RecyclerView
- ListAdapter
- Lists in Compose
- State
- Side-effects in Compose
- Jetpack Compose Effect Handlers
- Tail Recursive Functions in Kotlin
That’s all folks! Feel free to
comment or message me if you have any questions, suggestions or ideas! ?
Thanks to Chirag Kunder, Stojan Anastasov, and Mario Sanoguera de Lorenzo.