Jetpack Compose Interop: Using Compose in a RecyclerView
Compose UI 1.2.0-beta02bring 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
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.”
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
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 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
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
LazyRows could have an adapter like:
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
remembered state. You should only do this for parts of your UI that have
remembered 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
LazyRows) 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:
To take advantage of the improvements of using Compose in
RecyclerView, update your dependencies to at least
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
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.