• RecyclerView 1.3.0-alpha02 and Compose 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.

Compose used as items in a RecyclerView

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:

class MyComposeAdapter : RecyclerView.Adapter<MyComposeViewHolder>() {// NOTE: No need to explicitly call .disposeComposition()override fun onCreateViewHolder(parent: ViewGroup,viewType: Int,): MyComposeViewHolder {return MyComposeViewHolder(ComposeView(parent.context))}override fun onBindViewHolder(holder: ViewHolder, position: Int) {holder.bind("$position")}}class MyComposeViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {fun bind(input: String) {composeView.setContent {MdcTheme {Text(input)}}}}

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 <a class="au nt" href="" target="_blank" rel="noopener ugc nofollow">ViewCompositionStrategy</a> 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 <strong class="kj ja">DisposeOnDetachedFromWindow</strong>, 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.

Attached/detached views in RecyclerView

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):

import androidx.compose.ui.platform.ComposeViewclass MyComposeAdapter : RecyclerView.Adapter<MyComposeViewHolder>() {override fun onCreateViewHolder(parent: ViewGroup,viewType: Int,): MyComposeViewHolder {return MyComposeViewHolder(ComposeView(parent.context))}override fun onViewRecycled(holder: MyComposeViewHolder) {// This is from the previous guidance// NOTE: You **do not** want to do this with Compose 1.2.0-beta02+// and RecyclerView 1.3.0-alpha02+holder.composeView.disposeComposition()}/* Other methods */}class MyComposeViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {/* ... */}

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.

import androidx.compose.ui.platform.ViewCompositionStrategyclass MyComposeViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {init {// This is from the previous guidance// NOTE: **Do not** do this with Compose 1.2.0-beta02+// and RecyclerView 1.3.0-alpha02+composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)}fun bind(input: String) {composeView.setContent {MdcTheme {Text(input)}}}}

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:

@Composablefun ItemRow(index: Int, state: LazyListState) {DisposableEffect(Unit) {println("ItemRow $index composed")onDispose { println("ItemRow $index DISPOSED") }}Column(Modifier.fillMaxWidth()) {Text("Row #${index + 1}", Modifier.padding(horizontal = 8.dp))LazyRow(state = state) {items(25) { colIdx ->Column(Modifier.padding(8.dp).size(96.dp, 144.dp)) {Box(Modifier.fillMaxWidth().weight(0.75f).background(Color(0xFF999999)))Text("Item #$colIdx")}}}}}
RecyclerView containing ItemRows

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/ D/ItemRow: ItemRow 0 composed16:06:12.970 5619-5619/ D/ItemRow: ItemRow 1 composed16:06:13.047 5619-5619/ D/ItemRow: ItemRow 2 composed16:06:13.119 5619-5619/ D/ItemRow: ItemRow 3 composed16:06:13.196 5619-5619/ D/ItemRow: ItemRow 4 composed16:06:17.922 5619-5619/ D/ItemRow: ItemRow 5 composed16:06:19.033 5619-5619/ D/ItemRow: ItemRow 6 composed16:06:20.781 5619-5619/ D/ItemRow: ItemRow 7 composed16:06:20.909 5619-5619/ D/ItemRow: ItemRow 0 DISPOSED16:06:23.482 5619-5619/ D/ItemRow: ItemRow 8 composed16:06:23.527 5619-5619/ D/ItemRow: ItemRow 1 DISPOSED16:06:23.678 5619-5619/ D/ItemRow: ItemRow 9 composed16:06:23.752 5619-5619/ D/ItemRow: ItemRow 2 DISPOSED

Here we can see that ItemRow’s compositions with index 0, 1 and then 2 are disposed of 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 of 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 of 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, <a class="au nt" href="" target="_blank" rel="noopener ugc nofollow">ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool</a> 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/ D/ItemRow: ItemRow 0 composed16:17:27.796 6406-6406/ D/ItemRow: ItemRow 1 composed16:17:27.850 6406-6406/ D/ItemRow: ItemRow 2 composed16:17:27.909 6406-6406/ D/ItemRow: ItemRow 3 composed16:17:27.961 6406-6406/ D/ItemRow: ItemRow 4 composed16:17:31.747 6406-6406/ D/ItemRow: ItemRow 5 composed16:17:31.897 6406-6406/ D/ItemRow: ItemRow 6 composed16:17:32.313 6406-6406/ D/ItemRow: ItemRow 7 composed16:17:33.061 6406-6406/ 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.

LazyRow scroll state remembered across item views.

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:

@Composablefun ItemRow(index: Int, state: LazyListState) {Column(Modifier.fillMaxWidth()) {Text("Row #${index + 1}", Modifier.padding(horizontal = 8.dp))LazyRow(state = state) {// ...}}}class RecyclerViewAdapter : RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>() {private val rowStates = mutableMapOf<Int, LazyListState>()inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {val itemRow: ComposeItemRow = itemView.findViewById(}override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {val inflater = LayoutInflater.from(parent.context)return ViewHolder(inflater.inflate(R.layout.interop_demo_row, parent, false))}override fun onBindViewHolder(holder: ViewHolder, position: Int) {val state = rowStates.getOrPut(position) { LazyListState() }holder.itemRow.index = positionholder.itemRow.rowState = state}override fun getItemCount(): Int = 50}class ComposeItemRow @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyle: Int = 0) : AbstractComposeView(context, attrs, defStyle) {var index by mutableStateOf(0)var rowState: LazyListState? by mutableStateOf(null)@Composableoverride fun Content() {ItemRow(index, rowState!!)}}

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 <strong class="kj ja">remembered</strong> state in a <strong class="kj ja">key</strong>, 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:

@Composablefun ItemRow(index: Int) {Column(Modifier.fillMaxWidth()) {Text("Row #${index + 1}", Modifier.padding(horizontal = 8.dp))key(index) {LazyRow {// ...}}}}

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 <a class="au nt" href="" target="_blank" rel="noopener ugc nofollow">Animatable</a> 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 <a class="au nt" href="" target="_blank" rel="noopener ugc nofollow">onViewDetachedFromWindow(ViewHolder)</a> method, like so:

class ComposeItemRow @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyle: Int = 0) : AbstractComposeView(context, attrs, defStyle) {// …// State hoist animatable object so that it can be explicitly stoppedval animatable = Animatable(Color.Gray)@Composableoverride fun Content() {ItemRow(index, animatable)}}class MainAdapter : RecyclerView.Adapter<MainAdapter.ViewHolder>() {// …override fun onViewDetachedFromWindow(holder: ViewHolder) {super.onViewDetachedFromWindow(holder)// Stop the animation when the view gets detachedviewLifecycleOwner.lifecycleScope.launch {holder.itemRow.animatable.stop()}}}


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 <strong class="kj ja">disposeComposition()</strong> calls when views are recycled, as well as code setting the <strong class="kj ja">ViewCompositionStrategy</strong> to <strong class="kj ja">DisposeOnViewTreeLifecycleDestroyed</strong>. 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 MuntenescuRebecca Franks, and Simona Stojanovic for their reviews.

By Chris Arriola
Source Medium

Previous Make Your App Large Screen Ready
Next Get Familiar With Wear OS 3 (Without A Physical Device)