Jetpack Compose Interop: Using Compose in a RecyclerView
TL;DR
RecyclerView 1.3.0-alpha02
andCompose UI 1.2.0-beta02
bring out-of-the-box performant usage of composables from RecyclerView — no extra code required!- If you had previously implemented our guidance for Compose in RecyclerView, you should now remove this code.
Introducing Compose incrementally in your codebase means that you can end up in the situation when you’re using composables as items in a RecyclerView
. Before Compose UI version 1.2.0-beta02
, the underlying composition of a ComposeView
disposes when the view detaches from the window. However, in the context of a RecyclerView
, items continually detach/attach from the window as they move off/on screen. Having to dispose and recreate compositions repeatedly is expensive and has performance implications, especially when quickly flinging through the list.
Starting with Compose UI version 1.2.0-beta02 and RecyclerView version 1.3.0-alpha02, the view composition strategy used by the libraries has changed: the composition is now disposed of automatically when the view is detached from a window, unless it is part of a pooling container, such as RecyclerView. So when a ComposeView is used as an item in a RecyclerView, composables are no longer disposed, but rather re-used. This behavior change means that there is no work required from you to properly handle this. If you have implemented the previous guidance, you should remove it after updating to the latest libraries, as it will override the improved default behavior.
Here’s how your implementation should look like:
In this post, I will cover the background on why the previous guidance was recommended, and why we recommend that you update your implementation if you are on the aforementioned versions (or later) of RecyclerView and Compose to see better scrolling performance and simplify your code.
Understanding the previous default composition strategy: DisposeOnDetachedFromWindow
The ViewCompositionStrategy
of a ComposeView
determines when the underlying composition should be disposed of. Before version 1.2.0-beta02
of Compose UI, this value was configured to DisposeOnDetachedFromWindow
, which will dispose of the composition whenever the view detaches from the window. There are different scenarios when a view might detach from the window depending on the context, though generally this happens when the underlying container is going off screen or is about to be destroyed. While this is the behavior you’d want in most cases, this strategy is suboptimal in situations where views are frequently being detached and reattached to the window, such as in a RecyclerView
. Frequently disposing and recreating compositions can hurt scrolling performance, especially when quickly flinging through the list.
To mitigate this, ComposeView
composition disposal can be improved by disposing when the underlying view is recycled, not when it is detached from the window (note that this is still not the ideal case, as we will see later). We can listen to this event by overriding the onViewRecycled(ViewHolder)
method in the RecyclerView.Adapter
class. According to the documentation of that method, an underlying view will be recycled when:
“…a RecyclerView.LayoutManager decides that it no longer needs to be attached to its parent RecyclerView. This can be because it has fallen out of visibility or a set of cached views represented by views still attached to the parent RecyclerView. If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.”
In onViewRecycled(ViewHolder)
, we can call the disposeComposition()
method on the ComposeView
. This translates to the first part of the previous guidance (which is no longer recommended if you are on the aforementioned versions of Compose and RecyclerView
):
Additionally, to prevent the ComposeView
from being disposed when the view gets detached, we must set a different ViewCompositionStrategy
. Specifically, we must set it to DisposeOnViewTreeLifecycleDestroyed
so that the composition will be disposed of when the underlying lifecycle owner gets destroyed.
With these changes, the ComposeView’s
composition will no longer be automatically disposed when an item view detaches. So, we should be able to see the ComposeView
items dispose less as you scroll through the list. To validate this, let’s assume each item in a RecyclerView
is represented by an ItemRow
composable:
When an ItemRow
is composed, a DisposableEffect
also enters the composition which is used as a mechanism to print when it enters, followed by leaving the composition. With this setup, scrolling produces the following log statements:
16:06:12.840 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 0 composed16:06:12.970 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 1 composed16:06:13.047 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 2 composed16:06:13.119 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 3 composed16:06:13.196 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 4 composed16:06:17.922 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 5 composed16:06:19.033 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 6 composed16:06:20.781 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 7 composed16:06:20.909 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 0 DISPOSED16:06:23.482 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 8 composed16:06:23.527 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 1 DISPOSED16:06:23.678 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 9 composed16:06:23.752 5619-5619/com.google.samples.app.rv D/ItemRow: ItemRow 2 DISPOSED
Here we can see that ItemRow’s
compositions with index 0, 1 and then 2 are disposed as a result of the containing ComposeView
being recycled.
While these changes are certainly an improvement, a preferable behavior would be this:
Compositions should undergo recomposition when new data is rebound to the adapter. Additionally, compositions should only be disposed when we can be certain that the `ComposeView` will not be used again.
With this current solution, it is also possible for compositions to not be disposed when they should. Specifically, if the RecyclerView
gets detached from the window, but the contained Activity/Fragment lifecycle is still active, the compositions will not be disposed, resulting in compositions still being active despite no longer being needed.
There was no way around these limitations with the previously available APIs, which necessitated changes in both Compose and RecyclerView
to improve these behaviors.
Understanding the new default composition strategy: DisposeOnDetachedFromWindowOrReleasedFromPool
To support disposing compositions at the right time, the new strategy, ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
was introduced and set as the new default ViewCompositionStrategy
of a ComposeView
since version 1.2.0-beta02
of Compose UI. According to the documentation:
“The composition will be disposed automatically when the view is detached from a window, unless it is part of a pooling container, such as RecyclerView. When not within a pooling container, this behaves exactly the same as DisposeOnDetachedFromWindow.”
This new strategy behaves exactly as the previous default; however, it prevents disposing detached views in the context of a RecyclerView
, which is precisely what we want. This introduces the concept of a pooling container, which enables types that recycle through items communicate when an item should dispose of any resources it holds. The concept of a pooling container is implemented in a new artifact (androidx.customview.poolingcontainer), which both Compose UI and RecyclerView
depend on. Through interfaces provided in this artifact, RecyclerView
can communicate to Compose when compositions should be optimally disposed, making it no longer necessary to manually dispose compositions in onViewRecycled(ViewHolder)
. Instead, compositions will be disposed when either the item view is discarded (e.g., when the RecycledViewPool
is already full) or when the RecyclerView
is detached from the window. This implementation is more efficient than disposing compositions in onViewRecycled(ViewHolder)
, as it results in fewer unnecessary disposals due to having more information about the RecyclerView’s
item lifecycle.
With this new view composition strategy, using the same ItemRow
composables as items in a RecyclerView
, we can see that compositions are no longer disposed when scrolling through the list:
16:17:27.699 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 0 composed16:17:27.796 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 1 composed16:17:27.850 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 2 composed16:17:27.909 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 3 composed16:17:27.961 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 4 composed16:17:31.747 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 5 composed16:17:31.897 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 6 composed16:17:32.313 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 7 composed16:17:33.061 6406-6406/com.google.samples.app.rv D/ItemRow: ItemRow 8 composed
Remembering state
Compositions remain active while items are being recycled. This means that any internally remembered state will also be remembered even when binding the new data. For example, in the scenario of having a LazyRow within a RecyclerView like in ItemRow, the scroll position is remembered when the view is recycled. In the screenshot below, notice how the scroll position for row #1 also affects the scroll position of row #10.
To prevent this behavior, you have two options.
Option 1: If possible, you should hoist any item-specific state into the adapter. For example, the RecyclerView
of LazyRows
could have an adapter like:
The onBindViewHolder
method of this adapter creates the LazyListState
and sets it on the AbstractComposeView
subclass. It is stored in a delegated property using mutableStateOf
to ensure that the LazyRow
always has the correct state.
If you aren’t able to hoist the state, then there’s a simpler approach.
Option 2: wrap anything that has remembered
state in a key
, passing a value (or values) that uniquely identifies the item (if your list order never changes, the position will work). When the value changes, this will cause everything within the key to be fully recreated without any of the existing remember
ed state. You should only do this for parts of your UI that have remember
ed state, as this incurs a performance penalty to recreate the relevant parts of the composition. Additionally, it means that any state that you want to be restored when the item is scrolled into view again (such as the scroll position of the LazyRow
s) will be lost when the item is recycled.
For this approach, the ItemRow component would look something like:
Recompositions on detached items
Compositions will remain active despite being detached. This means that recompositions in response to state changes, such as animations, will continue to run. This can affect scrolling performance, so make sure to stop active animations when an item is going off screen.
For example, say you are using the Animatable
API to animate the background color of your RecyclerView
item. You can state hoist the Animatable
object to the item view and invoke stop()
in the adapter onViewDetachedFromWindow(ViewHolder)
method, like so:
Summary
To take advantage of the improvements of using Compose in RecyclerView
, update your dependencies to at least RecyclerView
version 1.3.0-alpha02
and Compose UI version 1.2.0-beta02
. If you previously followed our guidance, make sure to also remove explicit disposeComposition()
calls when views are recycled, as well as code setting the ViewCompositionStrategy
to DisposeOnViewTreeLifecycleDestroyed
. If you encounter any issues in the process, you can file an issue in our public issue tracker.
Still haven’t migrated your existing View-based app to Compose and want to learn more? Check out the Migrating to Jetpack Compose Codelab and the Sunflower sample app, which shows Views and Compose being used side-by-side.
If you have any questions, feel free to leave a comment on this post. In the meantime, happy composing!
The following post was written in collaboration with Ryan Mentley. Thanks to Florina Muntenescu, Rebecca Franks, and Simona Stojanovic for their reviews.