From fbd993ab67808a89cb4785c0629ed872fa406393 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sun, 28 Jan 2024 01:07:36 +0100 Subject: [PATCH] Add RecyclerViewCompositionStrategy.DisposeOnRecycled for re-using more compositions in RecyclerView --- .../api/dpadrecyclerview-compose.api | 11 ++++ .../compose/DpadComposeViewHolderTest.kt | 48 +++++++++++++++++- .../dpadrecyclerview/compose/TestActivity.kt | 31 +++++++++--- .../compose/TestComposable.kt | 27 ++++++++-- .../compose/DpadAbstractComposeViewHolder.kt | 1 + .../RecyclerViewCompositionStrategy.kt | 50 +++++++++++++++++++ 6 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy.kt diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index d7a38405..0dbdc1b5 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -19,3 +19,14 @@ public class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder : com public fun Content (Ljava/lang/Object;ZZLandroidx/compose/runtime/Composer;I)V } +public final class com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy { + public static final field $stable I + public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy; +} + +public final class com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy$DisposeOnRecycled : androidx/compose/ui/platform/ViewCompositionStrategy { + public static final field $stable I + public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy$DisposeOnRecycled; + public fun installFor (Landroidx/compose/ui/platform/AbstractComposeView;)Lkotlin/jvm/functions/Function0; +} + diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolderTest.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolderTest.kt index 8c81ec8d..0d32465c 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolderTest.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolderTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.recyclerview.widget.RecyclerView @@ -28,6 +29,7 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.matcher.ViewMatchers import com.google.common.truth.Truth.assertThat import com.rubensousa.dpadrecyclerview.DpadRecyclerView +import com.rubensousa.dpadrecyclerview.ExtraLayoutSpaceStrategy import com.rubensousa.dpadrecyclerview.testing.KeyEvents import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions import org.junit.Rule @@ -48,6 +50,15 @@ class DpadComposeViewHolderTest { assertFocus(item = 2, isFocused = false) assertSelection(item = 2, isSelected = false) + + KeyEvents.pressDown() + waitForIdleScroll() + + assertFocus(item = 0, isFocused = false) + assertSelection(item = 0, isSelected = false) + + assertFocus(item = 1, isFocused = true) + assertSelection(item = 1, isSelected = true) } @Test @@ -107,7 +118,7 @@ class DpadComposeViewHolderTest { } @Test - fun testCompositionIsCleared() { + fun testCompositionIsClearedWhenClearingAdapter() { val viewHolders = ArrayList() composeTestRule.activityRule.scenario.onActivity { activity -> viewHolders.addAll(activity.getViewsHolders()) @@ -121,6 +132,41 @@ class DpadComposeViewHolderTest { composeTestRule.onNodeWithText("0").assertDoesNotExist() } + @Test + fun testCompositionIsNotClearedWhenDetachingFromWindow() { + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.getRecyclerView().setExtraLayoutSpaceStrategy(object : ExtraLayoutSpaceStrategy { + override fun calculateStartExtraLayoutSpace(state: RecyclerView.State): Int { + return 1080 + } + }) + } + repeat(3) { + KeyEvents.pressDown() + waitForIdleScroll() + } + + composeTestRule.onNodeWithText("0").assertExists() + composeTestRule.onNodeWithText("0").assertIsNotDisplayed() + } + + @Test + fun testCompositionIsClearedWhenViewHolderIsRecycled() { + repeat(5) { + KeyEvents.pressDown() + waitForIdleScroll() + } + + composeTestRule.onNodeWithText("0").assertDoesNotExist() + + var disposals: List = emptyList() + composeTestRule.activityRule.scenario.onActivity { activity -> + disposals = activity.getDisposals() + } + + assertThat(disposals).contains(0) + } + private fun waitForIdleScroll() { onView(ViewMatchers.isAssignableFrom(DpadRecyclerView::class.java)) .perform(DpadRecyclerViewActions.waitForIdleScroll()) diff --git a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt index f75412df..d47452d9 100644 --- a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt @@ -32,12 +32,18 @@ class TestActivity : AppCompatActivity() { private lateinit var recyclerView: DpadRecyclerView private val clicks = ArrayList() + private val disposals = ArrayList() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.compose_test) recyclerView = findViewById(R.id.recyclerView) - recyclerView.adapter = Adapter(List(100) { it }) + recyclerView.adapter = Adapter( + items = List(100) { it }, + onDispose = { item -> + disposals.add(item) + } + ) recyclerView.requestFocus() } @@ -53,10 +59,16 @@ class TestActivity : AppCompatActivity() { return clicks } + fun getDisposals(): List { + return disposals + } + fun removeAdapter() { recyclerView.adapter = null } + fun getRecyclerView(): DpadRecyclerView = recyclerView + fun getViewsHolders(): List { val viewHolders = ArrayList() recyclerView.children.forEach { child -> @@ -65,14 +77,17 @@ class TestActivity : AppCompatActivity() { return viewHolders } - inner class Adapter(private val items: List) : - RecyclerView.Adapter>() { + inner class Adapter( + private val items: List, + private val onDispose: (item: Int) -> Unit, + ) : RecyclerView.Adapter>() { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): DpadComposeViewHolder { - return DpadComposeViewHolder(parent, + return DpadComposeViewHolder( + parent, composable = { item, isFocused, isSelected -> TestComposable( modifier = Modifier @@ -80,13 +95,17 @@ class TestActivity : AppCompatActivity() { .height(150.dp), item = item, isFocused = isFocused, - isSelected = isSelected + isSelected = isSelected, + onDispose = { + onDispose(item) + } ) }, onClick = { clicks.add(it) }, - isFocusable = true) + isFocusable = true + ) } override fun getItemCount(): Int = items.size diff --git a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt index 2f1c3ef0..868071dc 100644 --- a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt @@ -18,8 +18,10 @@ package com.rubensousa.dpadrecyclerview.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,7 +39,8 @@ fun TestComposable( modifier: Modifier = Modifier, item: Int, isFocused: Boolean, - isSelected: Boolean + isSelected: Boolean, + onDispose: () -> Unit = {}, ) { val backgroundColor = if (isFocused) { Color.White @@ -51,10 +54,24 @@ fun TestComposable( .background(backgroundColor), contentAlignment = Alignment.Center, ) { - Text(modifier = Modifier.semantics { - set(TestComposable.focusedKey, isFocused) - set(TestComposable.selectedKey, isSelected) - }, text = item.toString()) + Text( + modifier = Modifier.semantics { + set(TestComposable.focusedKey, isFocused) + set(TestComposable.selectedKey, isSelected) + }, + text = item.toString(), + style = MaterialTheme.typography.headlineLarge, + color = if(isFocused) { + Color.Black + } else { + Color.White + } + ) + } + DisposableEffect(key1 = item) { + onDispose { + onDispose() + } } } diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt index c8d4c68f..b6df0e5a 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt @@ -50,6 +50,7 @@ abstract class DpadAbstractComposeViewHolder( init { val composeView = itemView as ComposeView + composeView.setViewCompositionStrategy(RecyclerViewCompositionStrategy.DisposeOnRecycled) composeView.isFocusable = isFocusable composeView.isFocusableInTouchMode = isFocusable composeView.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy.kt new file mode 100644 index 00000000..21a41668 --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 RĂºben Sousa + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rubensousa.dpadrecyclerview.compose + +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.customview.poolingcontainer.PoolingContainerListener +import androidx.customview.poolingcontainer.addPoolingContainerListener +import androidx.customview.poolingcontainer.removePoolingContainerListener +import com.rubensousa.dpadrecyclerview.DpadRecyclerView + +object RecyclerViewCompositionStrategy { + + /** + * Similar to [ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool] but skips + * releasing compositions when detached from window. This is useful for re-using compositions + * a lot more often when scrolling a RecyclerView. + * + * If you use [DpadRecyclerView.setRecycleChildrenOnDetach], + * this will behave exactly the same as [ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool]. + * + * If you use [DpadRecyclerView.setExtraLayoutSpaceStrategy], + * please profile the compositions before considering using this strategy. + */ + object DisposeOnRecycled : ViewCompositionStrategy { + override fun installFor(view: AbstractComposeView): () -> Unit { + val poolingContainerListener = PoolingContainerListener { + view.disposeComposition() + } + view.addPoolingContainerListener(poolingContainerListener) + return { + view.removePoolingContainerListener(poolingContainerListener) + } + } + } + +}