diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 57897618..a4927daf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -116,11 +116,11 @@ jobs: ram-size: 4096M emulator-options: -no-window -no-snapshot-save -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | - ./scripts/install_test_apks.sh - ./scripts/run_instrumented_tests.sh dpadrecyclerview - ./scripts/run_instrumented_tests.sh dpadrecyclerview-testing - ./scripts/run_instrumented_tests.sh dpadrecyclerview-compose - ./scripts/run_instrumented_tests.sh sample + ./gradlew uninstallAll + ./gradlew --build-cache dpadrecyclerview-compose:connectedDebugAndroidTest + ./gradlew --build-cache sample:connectedDebugAndroidTest + ./gradlew --build-cache dpadrecyclerview-testing:connectedDebugAndroidTest + ./gradlew --build-cache dpadrecyclerview:connectedDebugAndroidTest --info - name: Upload artifacts uses: actions/upload-artifact@v3 diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index be534b34..faaa5b19 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -1,22 +1,29 @@ -public abstract class com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder { +public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensionsKt { + public static final fun dpadClickable (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; +} + +public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder { public static final field $stable I - public fun (Landroid/view/ViewGroup;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;)V - public synthetic fun (Landroid/view/ViewGroup;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public abstract fun Content (Ljava/lang/Object;ZZLandroidx/compose/runtime/Composer;I)V + public fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function4;)V + public synthetic fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function4;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getItem ()Ljava/lang/Object; public fun getSubPositionAlignments ()Ljava/util/List; - public fun onFocusChanged (Z)V public fun onViewHolderDeselected ()V public fun onViewHolderSelected ()V public fun onViewHolderSelectedAndAligned ()V public final fun setItemState (Ljava/lang/Object;)V } -public class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder : com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder { +public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder { public static final field $stable I public fun (Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function5;)V public synthetic fun (Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function5;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun Content (Ljava/lang/Object;ZZLandroidx/compose/runtime/Composer;I)V + public final fun getItem ()Ljava/lang/Object; + public fun getSubPositionAlignments ()Ljava/util/List; + public fun onViewHolderDeselected ()V + public fun onViewHolderSelected ()V + public fun onViewHolderSelectedAndAligned ()V + public final fun setItemState (Ljava/lang/Object;)V } public final class com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy { diff --git a/dpadrecyclerview-compose/build.gradle b/dpadrecyclerview-compose/build.gradle index 0df1fa5e..7b00b90e 100644 --- a/dpadrecyclerview-compose/build.gradle +++ b/dpadrecyclerview-compose/build.gradle @@ -15,6 +15,7 @@ android { targetSdk versions.targetSdkVersion testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments useTestStorageService: 'true' + testInstrumentationRunnerArguments additionalTestOutputDir: 'storage/emulated/0/recordings/com.rubensousa.dpadrecyclerview.compose.test' testInstrumentationRunnerArguments listener: 'com.rubensousa.dpadrecyclerview.testfixtures.recording.TestRecordingListener' } @@ -57,14 +58,14 @@ dependencies { implementation libs.androidx.appcompat implementation libs.androidx.recyclerview implementation libs.androidx.customview.poolingcontainer + implementation libs.androidx.compose.foundation implementation libs.androidx.compose.ui + implementation libs.androidx.compose.ui.tooling.preview // Test dependencies debugImplementation libs.androidx.test.compose.ui.manifest debugImplementation libs.androidx.compose.ui.tooling - debugImplementation libs.androidx.compose.ui.tooling.preview debugImplementation libs.androidx.compose.material3 - debugImplementation libs.androidx.customview androidTestImplementation project(':dpadrecyclerview-testing') androidTestImplementation project(':dpadrecyclerview-test-fixtures') androidTestImplementation libs.androidx.test.compose.ui.junit4 diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt new file mode 100644 index 00000000..82557d85 --- /dev/null +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023 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.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 +import androidx.test.espresso.Espresso +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.testfixtures.recording.ScreenRecorderRule +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions +import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule +import org.junit.Rule +import org.junit.Test + +class DpadComposeFocusViewHolderTest { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + @get:Rule + val screenRecorderRule = ScreenRecorderRule() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun testComposeItemsReceiveFocus() { + assertFocus(item = 0, isFocused = true) + assertSelection(item = 0, isSelected = true) + + KeyEvents.pressDown() + waitForIdleScroll() + + assertFocus(item = 1, isFocused = true) + assertSelection(item = 1, isSelected = true) + } + + @Test + fun testComposeFocusChanges() { + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.clearFocus() + } + + Espresso.onIdle() + assertFocus(item = 0, isFocused = false) + assertSelection(item = 0, isSelected = true) + + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.requestFocus() + } + + assertFocus(item = 0, isFocused = true) + assertSelection(item = 0, isSelected = true) + } + + @Test + fun testClicksAreDispatched() { + // given + var clicks: List = emptyList() + composeTestRule.activityRule.scenario.onActivity { activity -> + clicks = activity.getClicks() + } + + // when + KeyEvents.click() + + // then + assertThat(clicks).isEqualTo(listOf(0)) + } + + @Test + fun testCompositionIsClearedWhenClearingAdapter() { + val viewHolders = ArrayList() + composeTestRule.activityRule.scenario.onActivity { activity -> + viewHolders.addAll(activity.getViewsHolders()) + activity.removeAdapter() + } + + viewHolders.forEach { viewHolder -> + val composeView = viewHolder.itemView as DpadComposeView + assertThat(composeView.hasComposition()).isFalse() + } + 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(10) { + 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()) + } + + private fun assertFocus(item: Int, isFocused: Boolean) { + composeTestRule.onNodeWithText(item.toString()).assertIsDisplayed() + .assert(SemanticsMatcher.expectValue(TestComposable.focusedKey, isFocused)) + } + + private fun assertSelection(item: Int, isSelected: Boolean) { + composeTestRule.onNodeWithText(item.toString()).assertIsDisplayed() + .assert(SemanticsMatcher.expectValue(TestComposable.selectedKey, isSelected)) + } + +} 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 0c09d7d8..5a7f0e7b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Rúben Sousa + * 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. @@ -13,10 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.rubensousa.dpadrecyclerview.compose -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed @@ -30,33 +28,32 @@ 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.testfixtures.recording.ScreenRecorderRule import com.rubensousa.dpadrecyclerview.testing.KeyEvents import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions +import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule import org.junit.Rule import org.junit.Test class DpadComposeViewHolderTest { @get:Rule - val composeTestRule = createAndroidComposeRule() + val idleTimeoutRule = DisableIdleTimeoutRule() + + @get:Rule + val screenRecorderRule = ScreenRecorderRule() + + @get:Rule + val composeTestRule = createAndroidComposeRule() @Test fun testComposeItemsReceiveFocus() { assertFocus(item = 0, isFocused = true) assertSelection(item = 0, isSelected = true) - assertFocus(item = 1, isFocused = false) - assertSelection(item = 1, isSelected = false) - - 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) } @@ -71,24 +68,12 @@ class DpadComposeViewHolderTest { assertFocus(item = 0, isFocused = false) assertSelection(item = 0, isSelected = true) - assertFocus(item = 1, isFocused = false) - assertSelection(item = 1, isSelected = false) - - assertFocus(item = 2, isFocused = false) - assertSelection(item = 2, isSelected = false) - composeTestRule.activityRule.scenario.onActivity { activity -> activity.requestFocus() } assertFocus(item = 0, isFocused = true) assertSelection(item = 0, isSelected = true) - - assertFocus(item = 1, isFocused = false) - assertSelection(item = 1, isSelected = false) - - assertFocus(item = 2, isFocused = false) - assertSelection(item = 2, isSelected = false) } @Test @@ -102,19 +87,17 @@ class DpadComposeViewHolderTest { @Test fun testClicksAreDispatched() { - KeyEvents.click() - - KeyEvents.pressDown() - waitForIdleScroll() - - KeyEvents.click() - + // given var clicks: List = emptyList() composeTestRule.activityRule.scenario.onActivity { activity -> clicks = activity.getClicks() } - assertThat(clicks).isEqualTo(listOf(0, 1)) + // when + KeyEvents.click() + + // then + assertThat(clicks).isEqualTo(listOf(0)) } @Test @@ -126,8 +109,8 @@ class DpadComposeViewHolderTest { } viewHolders.forEach { viewHolder -> - val composeView = viewHolder.itemView as ComposeView - assertThat(composeView.hasComposition).isFalse() + val composeView = viewHolder.itemView as DpadComposeView + assertThat(composeView.hasComposition()).isFalse() } composeTestRule.onNodeWithText("0").assertDoesNotExist() } @@ -183,5 +166,3 @@ class DpadComposeViewHolderTest { } } - - diff --git a/dpadrecyclerview-compose/src/debug/AndroidManifest.xml b/dpadrecyclerview-compose/src/debug/AndroidManifest.xml index 56aad10c..4d81f0f9 100644 --- a/dpadrecyclerview-compose/src/debug/AndroidManifest.xml +++ b/dpadrecyclerview-compose/src/debug/AndroidManifest.xml @@ -28,7 +28,7 @@ @@ -37,6 +37,15 @@ + + + + + + diff --git a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt similarity index 84% rename from dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt rename to dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt index d47452d9..6a50d082 100644 --- a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt @@ -28,7 +28,7 @@ import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.DpadRecyclerView -class TestActivity : AppCompatActivity() { +class ComposeFocusTestActivity : AppCompatActivity() { private lateinit var recyclerView: DpadRecyclerView private val clicks = ArrayList() @@ -80,37 +80,35 @@ class TestActivity : AppCompatActivity() { inner class Adapter( private val items: List, private val onDispose: (item: Int) -> Unit, - ) : RecyclerView.Adapter>() { + ) : RecyclerView.Adapter>() { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): DpadComposeViewHolder { - return DpadComposeViewHolder( - parent, - composable = { item, isFocused, isSelected -> - TestComposable( + ): DpadComposeFocusViewHolder { + return DpadComposeFocusViewHolder( + parent = parent, + content = { item, isSelected -> + TestComposableFocus( modifier = Modifier .fillMaxWidth() .height(150.dp), item = item, - isFocused = isFocused, isSelected = isSelected, + onClick = { + clicks.add(item) + }, onDispose = { onDispose(item) } ) }, - onClick = { - clicks.add(it) - }, - isFocusable = true ) } override fun getItemCount(): Int = items.size - override fun onBindViewHolder(holder: DpadComposeViewHolder, position: Int) { + override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { holder.setItemState(items[position]) } 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 868071dc..4fde3db2 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 @@ -17,13 +17,23 @@ package com.rubensousa.dpadrecyclerview.compose import androidx.compose.foundation.background +import androidx.compose.foundation.clickable 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.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.semantics @@ -75,20 +85,86 @@ fun TestComposable( } } +@Composable +fun TestComposableFocus( + modifier: Modifier = Modifier, + item: Int, + isSelected: Boolean, + onClick: () -> Unit, + onDispose: () -> Unit = {}, +) { + var isFocused by remember { mutableStateOf(false) } + val backgroundColor = if (isFocused) { + Color.White + } else if (isSelected) { + Color.Blue + } else { + Color.Black + } + Box( + modifier = modifier + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + } + .focusTarget() + .background(backgroundColor) + .clickable { + onClick() + }, + contentAlignment = Alignment.Center, + ) { + 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() + } + } +} + @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewNormal() { - TestComposable(item = 0, isFocused = false, isSelected = false) + TestComposableFocus( + item = 0, + isSelected = false, + onClick = {} + ) } @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewFocused() { - TestComposable(item = 0, isFocused = true, isSelected = false) + val focusRequester = remember { FocusRequester() } + TestComposableFocus( + item = 0, + modifier = Modifier.focusRequester(focusRequester), + isSelected = false, + onClick = {} + ) + SideEffect { + focusRequester.requestFocus() + } } @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewSelected() { - TestComposable(item = 0, isFocused = false, isSelected = true) + TestComposableFocus( + item = 0, + isSelected = true, + onClick = {} + ) } \ No newline at end of file diff --git a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt new file mode 100644 index 00000000..a71de721 --- /dev/null +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2023 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 android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadRecyclerView + +class ViewFocusTestActivity : 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( + items = List(100) { it }, + onDispose = { item -> + disposals.add(item) + } + ) + recyclerView.requestFocus() + } + + fun requestFocus() { + recyclerView.requestFocus() + } + + fun clearFocus() { + findViewById(R.id.focusPlaceholder).requestFocus() + } + + fun getClicks(): List { + 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 -> + viewHolders.add(recyclerView.getChildViewHolder(child)) + } + return viewHolders + } + + 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 = parent, + onClick = { + clicks.add(it) + }, + isFocusable = true + ) { item, isFocused, isSelected -> + TestComposable( + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + item = item, + isFocused = isFocused, + isSelected = isSelected, + onDispose = { + onDispose(item) + } + ) + } + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: DpadComposeViewHolder, position: Int) { + holder.setItemState(items[position]) + } + + } +} 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 deleted file mode 100644 index 9173ee9b..00000000 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2023 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 android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.recyclerview.widget.RecyclerView -import com.rubensousa.dpadrecyclerview.DpadViewHolder - -/** - * A ViewHolder that will render a [Composable] in [Content]. - * - * Focus is kept inside the internal [ComposeView] to ensure that it behaves correctly - * and to workaround the following issues: - * - * 1. Focus is not sent correctly from Views to Composables: - * [b/268248352](https://issuetracker.google.com/issues/268248352) - * This is solved by just holding the focus in [ComposeView] - * - * 2. Clicking on a focused Composable does not trigger the standard audio feedback: - * [b/268268856](https://issuetracker.google.com/issues/268268856) - * This is solved by just handling the click on [ComposeView] directly - * - * Check the default implementation at [DpadComposeViewHolder] - */ -abstract class DpadAbstractComposeViewHolder( - parent: ViewGroup, - isFocusable: Boolean = true, - compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled -) : RecyclerView.ViewHolder(ComposeView(parent.context)), DpadViewHolder { - - private val itemState = mutableStateOf(null) - private val focusState = mutableStateOf(false) - private val selectionState = mutableStateOf(false) - - init { - val composeView = itemView as ComposeView - composeView.setViewCompositionStrategy(compositionStrategy) - composeView.isFocusable = isFocusable - composeView.isFocusableInTouchMode = isFocusable - composeView.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS - composeView.setOnFocusChangeListener { _, hasFocus -> - focusState.value = hasFocus - onFocusChanged(hasFocus) - } - composeView.setContent { - itemState.value?.let { item -> - Content(item, focusState.value, selectionState.value) - } - } - } - - @Composable - abstract fun Content(item: T, isFocused: Boolean, isSelected: Boolean) - - override fun onViewHolderSelected() { - selectionState.value = true - } - - override fun onViewHolderDeselected() { - selectionState.value = false - } - - open fun onFocusChanged(hasFocus: Boolean) { - - } - - fun setItemState(item: T?) { - itemState.value = item - } - - fun getItem(): T? = itemState.value - -} diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt new file mode 100644 index 00000000..f716f31a --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt @@ -0,0 +1,41 @@ +/* + * 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 android.content.Context +import android.media.AudioManager +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext + +/** + * Similar to [Modifier.clickable], but triggers a sound effect on click. + * Workaround for: https://issuetracker.google.com/issues/268268856 + */ +@Composable +fun Modifier.dpadClickable(action: () -> Unit): Modifier { + val context = LocalContext.current + val audioManager = remember { + context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + } + return then(Modifier.clickable { + audioManager?.playSoundEffect(AudioManager.FX_KEY_CLICK) + action() + }) +} \ No newline at end of file diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt new file mode 100644 index 00000000..b667b8a6 --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -0,0 +1,84 @@ +/* + * 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 android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadViewHolder + +/** + * Similar to [DpadComposeViewHolder], but sends the focus down to composables + * + * This allows inline definition of ViewHolders in `onCreateViewHolder`: + * + * ```kotlin + * override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DpadComposeFocusViewHolder { + * return DpadComposeFocusViewHolder(parent) { item, isSelected -> + * ItemComposable(item, isSelected) + * } + * } + * ``` + * To update the current item, override `onBindViewHolder` and call [setItemState]: + * + * ```kotlin + * override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { + * holder.setItemState(getItem(position)) + * } + * ``` + */ +class DpadComposeFocusViewHolder( + parent: ViewGroup, + compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, + private val content: @Composable (item: T, isSelected: Boolean) -> Unit +) : RecyclerView.ViewHolder(DpadComposeView(parent.context)), DpadViewHolder { + + private val itemState = mutableStateOf(null) + private val selectionState = mutableStateOf(false) + + init { + val composeView = itemView as DpadComposeView + composeView.apply { + setFocusConfiguration( + isFocusable = true, + dispatchFocusToComposable = true + ) + setViewCompositionStrategy(compositionStrategy) + setContent { + itemState.value?.let { item -> + content(item, selectionState.value) + } + } + } + } + + override fun onViewHolderSelected() { + selectionState.value = true + } + + override fun onViewHolderDeselected() { + selectionState.value = false + } + + fun setItemState(item: T?) { + itemState.value = item + } + + fun getItem(): T? = itemState.value +} diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt new file mode 100644 index 00000000..9f91935c --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt @@ -0,0 +1,77 @@ +/* + * 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 android.content.Context +import android.util.AttributeSet +import android.view.View.OnFocusChangeListener +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy + +/** + * A wrapper for [ComposeView] to allow keeping focus inside the view system + */ +internal class DpadComposeView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + private val composeView = ComposeView(context) + private val internalFocusListener = OnFocusChangeListener { v, hasFocus -> + focusListener?.onFocusChange(v, hasFocus) + } + private var focusListener: OnFocusChangeListener? = null + + init { + addView(composeView) + clipChildren = false + super.setOnFocusChangeListener(internalFocusListener) + } + + override fun setOnFocusChangeListener(listener: OnFocusChangeListener?) { + focusListener = listener + } + + fun setFocusConfiguration( + isFocusable: Boolean, + dispatchFocusToComposable: Boolean + ) { + if (dispatchFocusToComposable) { + composeView.isFocusable = isFocusable + composeView.isFocusableInTouchMode = isFocusable + composeView.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + this.isFocusable = false + this.isFocusableInTouchMode = false + } else { + this.isFocusable = isFocusable + this.isFocusableInTouchMode = isFocusable + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + } + } + + fun setViewCompositionStrategy(strategy: ViewCompositionStrategy) { + composeView.setViewCompositionStrategy(strategy) + } + + fun setContent(content: @Composable () -> Unit) { + composeView.setContent(content) + } + + internal fun hasComposition() = composeView.hasComposition +} diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt index 0fb88789..e2e0d657 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt @@ -17,11 +17,26 @@ package com.rubensousa.dpadrecyclerview.compose import android.view.ViewGroup import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadViewHolder /** - * A basic implementation of [DpadAbstractComposeViewHolder] - * that forwards [Content] to [composable] and handles clicks. + * A basic ViewHolder that forwards [Content] to [composable] + * and handles focus and clicks inside the View system. + * + * Focus is kept inside the internal [ComposeView] to ensure that it behaves correctly + * and to workaround the following issues: + * + * 1. Focus is not sent correctly from Views to Composables: + * [b/268248352](https://issuetracker.google.com/issues/268248352) + * This is solved by just holding the focus in [ComposeView] + * + * 2. Clicking on a focused Composable does not trigger the standard audio feedback: + * [b/268268856](https://issuetracker.google.com/issues/268268856) + * This is solved by just handling the click on [ComposeView] directly * * This allows inline definition of ViewHolders in `onCreateViewHolder`: * @@ -40,16 +55,36 @@ import androidx.compose.ui.platform.ViewCompositionStrategy * } * ``` */ -open class DpadComposeViewHolder( +class DpadComposeViewHolder( parent: ViewGroup, onClick: ((item: T) -> Unit)? = null, onLongClick: ((item: T) -> Boolean)? = null, isFocusable: Boolean = true, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, private val composable: DpadComposable, -) : DpadAbstractComposeViewHolder(parent, isFocusable, compositionStrategy) { +) : RecyclerView.ViewHolder(DpadComposeView(parent.context)), DpadViewHolder { + + private val focusState = mutableStateOf(false) + private val itemState = mutableStateOf(null) + private val selectionState = mutableStateOf(false) init { + val composeView = itemView as DpadComposeView + composeView.apply { + setFocusConfiguration( + isFocusable = isFocusable, + dispatchFocusToComposable = false + ) + setOnFocusChangeListener { _, hasFocus -> + focusState.value = hasFocus + } + setViewCompositionStrategy(compositionStrategy) + setContent { + itemState.value?.let { item -> + composable(item, focusState.value, selectionState.value) + } + } + } if (onClick != null) { itemView.setOnClickListener { getItem()?.let(onClick) @@ -63,11 +98,19 @@ open class DpadComposeViewHolder( } } - @Composable - override fun Content(item: T, isFocused: Boolean, isSelected: Boolean) { - composable(item, isFocused, isSelected) + override fun onViewHolderSelected() { + selectionState.value = true + } + + override fun onViewHolderDeselected() { + selectionState.value = false + } + + fun setItemState(item: T?) { + itemState.value = item } + fun getItem(): T? = itemState.value } typealias DpadComposable = @Composable (T, Boolean, Boolean) -> Unit diff --git a/dpadrecyclerview-testing/build.gradle b/dpadrecyclerview-testing/build.gradle index b9ed4454..4acad5bb 100644 --- a/dpadrecyclerview-testing/build.gradle +++ b/dpadrecyclerview-testing/build.gradle @@ -15,6 +15,7 @@ android { targetSdk versions.targetSdkVersion testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments useTestStorageService: 'true' + testInstrumentationRunnerArguments additionalTestOutputDir: 'storage/emulated/0/recordings/com.rubensousa.dpadrecyclerview.testing.test' testInstrumentationRunnerArguments listener: 'com.rubensousa.dpadrecyclerview.testfixtures.recording.TestRecordingListener' multiDexEnabled true } diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index 62c25577..3c26fb8e 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -100,6 +100,7 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle public final fun removeOnViewHolderSelectedListener (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;)V public final fun removeView (Landroid/view/View;)V public final fun removeViewAt (I)V + public final fun requestLayout ()V public final fun setAlignments (Lcom/rubensousa/dpadrecyclerview/ParentAlignment;Lcom/rubensousa/dpadrecyclerview/ChildAlignment;Z)V public final fun setChildAlignment (Lcom/rubensousa/dpadrecyclerview/ChildAlignment;Z)V public static synthetic fun setChildAlignment$default (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;Lcom/rubensousa/dpadrecyclerview/ChildAlignment;ZILjava/lang/Object;)V @@ -120,6 +121,7 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle public final fun setItemPrefetchEnabled (Z)V public final fun setLayoutEnabled (Z)V public final fun setLayoutManager (Landroidx/recyclerview/widget/RecyclerView$LayoutManager;)V + public final fun setLayoutWhileScrollingEnabled (Z)V public final fun setLoopDirection (Lcom/rubensousa/dpadrecyclerview/DpadLoopDirection;)V public final fun setMaxEdgeFadingLength (I)V public final fun setMaxEdgeFadingOffset (I)V diff --git a/dpadrecyclerview/build.gradle b/dpadrecyclerview/build.gradle index 3966bfcb..4564e14d 100644 --- a/dpadrecyclerview/build.gradle +++ b/dpadrecyclerview/build.gradle @@ -15,6 +15,7 @@ android { targetSdk versions.targetSdkVersion testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments useTestStorageService: 'true' + testInstrumentationRunnerArguments additionalTestOutputDir: 'storage/emulated/0/recordings/com.rubensousa.dpadrecyclerview.test' testInstrumentationRunnerArguments listener: 'com.rubensousa.dpadrecyclerview.testfixtures.recording.TestRecordingListener' } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/LayoutWhileScrollingTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/LayoutWhileScrollingTest.kt new file mode 100644 index 00000000..af424ec0 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/LayoutWhileScrollingTest.kt @@ -0,0 +1,151 @@ +/* + * 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.test.tests.layout + +import androidx.recyclerview.widget.RecyclerView +import com.google.common.truth.Truth.assertThat +import com.rubensousa.dpadrecyclerview.ChildAlignment +import com.rubensousa.dpadrecyclerview.DpadRecyclerView +import com.rubensousa.dpadrecyclerview.ParentAlignment +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.selectLastPosition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState +import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class LayoutWhileScrollingTest : DpadRecyclerViewTest() { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + override fun getDefaultLayoutConfiguration(): TestLayoutConfiguration { + return TestLayoutConfiguration( + spans = 1, + orientation = RecyclerView.VERTICAL, + parentAlignment = ParentAlignment( + edge = ParentAlignment.Edge.NONE, + fraction = 0.5f + ), + childAlignment = ChildAlignment( + fraction = 0.5f + ) + ) + } + + override fun getDefaultAdapterConfiguration(): TestAdapterConfiguration { + return super.getDefaultAdapterConfiguration().copy(numberOfItems = 100) + } + + @Before + fun setup() { + launchFragment() + } + + @Test + fun testRequestingLayoutDuringSmoothScrollIsIgnored() { + // given + var layoutCompleted = 0 + onRecyclerView("Disable layout during scroll") { recyclerView -> + recyclerView.setLayoutWhileScrollingEnabled(false) + recyclerView.addOnLayoutCompletedListener( + object : DpadRecyclerView.OnLayoutCompletedListener { + override fun onLayoutCompleted(state: RecyclerView.State) { + layoutCompleted++ + } + }) + } + + // when + selectLastPosition(smooth = true) + repeat(10) { + onRecyclerView("Request layout") { recyclerView -> + if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { + recyclerView.requestLayout() + } + } + } + waitForIdleScrollState() + + // then + assertThat(layoutCompleted).isEqualTo(1) + } + + @Test + fun testRequestingLayoutDuringKeyEventsIsIgnored() { + // given + var layoutCompleted = 0 + onRecyclerView("Disable layout during scroll") { recyclerView -> + recyclerView.setLayoutWhileScrollingEnabled(false) + recyclerView.addOnLayoutCompletedListener( + object : DpadRecyclerView.OnLayoutCompletedListener { + override fun onLayoutCompleted(state: RecyclerView.State) { + layoutCompleted++ + } + }) + } + + // when + KeyEvents.pressDown(times = 20) + repeat(10) { + onRecyclerView("Request layout") { recyclerView -> + if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { + recyclerView.requestLayout() + } + } + } + waitForIdleScrollState() + + // then + assertThat(layoutCompleted).isEqualTo(1) + } + + @Test + fun testRequestingLayoutDuringScrollIsNotIgnored() { + // given + var layoutCompleted = 0 + val layoutRequests = 2 + onRecyclerView("Enable layout during scroll") { recyclerView -> + recyclerView.setLayoutWhileScrollingEnabled(true) + recyclerView.addOnLayoutCompletedListener( + object : DpadRecyclerView.OnLayoutCompletedListener { + override fun onLayoutCompleted(state: RecyclerView.State) { + layoutCompleted++ + } + }) + } + + // when + selectLastPosition(smooth = true) + repeat(layoutRequests) { + onRecyclerView("Request layout") { recyclerView -> + recyclerView.requestLayout() + } + } + waitForIdleScrollState() + + // then + assertThat(layoutCompleted).isEqualTo(layoutRequests) + } + +} + diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index 909a0128..b4aac66a 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -71,6 +71,8 @@ open class DpadRecyclerView @JvmOverloads constructor( private var isOverlappingRenderingEnabled = true private var isRetainingFocus = false private var startedTouchScroll = false + private var layoutWhileScrollingEnabled = true + private var hasPendingLayout = false private var touchInterceptListener: OnTouchInterceptListener? = null private var smoothScrollByBehavior: SmoothScrollByBehavior? = null private var keyInterceptListener: OnKeyInterceptListener? = null @@ -226,6 +228,15 @@ open class DpadRecyclerView @JvmOverloads constructor( } } + final override fun requestLayout() { + if (layoutWhileScrollingEnabled || scrollState == SCROLL_STATE_IDLE) { + hasPendingLayout = false + super.requestLayout() + return + } + hasPendingLayout = true + } + // Overriding to prevent WRAP_CONTENT behavior by replacing it // with the size defined by the parent. Leanback also doesn't support WRAP_CONTENT final override fun onMeasure(widthSpec: Int, heightSpec: Int) { @@ -419,6 +430,10 @@ open class DpadRecyclerView @JvmOverloads constructor( if (state == SCROLL_STATE_IDLE) { startedTouchScroll = false pivotLayoutManager?.setScrollingFromTouchEvent(false) + if (hasPendingLayout) { + hasPendingLayout = false + requestLayout() + } } else if (startedTouchScroll) { pivotLayoutManager?.setScrollingFromTouchEvent(true) } @@ -1228,6 +1243,21 @@ open class DpadRecyclerView @JvmOverloads constructor( */ fun getOnMotionInterceptListener(): OnMotionInterceptListener? = motionInterceptListener + /** + * By default, [DpadRecyclerView] allows triggering a layout-pass during scrolling. + * However, there might be some cases where someone is interested in disabling this behavior, + * for example: + * 1. Compose animations trigger a full unnecessary layout-pass + * 2. Content jumping around while scrolling is not ideal sometimes + * + * @param enabled true if layout requests should be possible while scrolling, + * or false if they should be postponed until [RecyclerView.SCROLL_STATE_IDLE]. + * Default is true. + */ + fun setLayoutWhileScrollingEnabled(enabled: Boolean) { + layoutWhileScrollingEnabled = enabled + } + @VisibleForTesting internal fun detachFromWindow() { onDetachedFromWindow() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cccec27c..2856f5e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidx-collection = "1.4.0" androidx-concurrent-futures = "1.1.0" androidx-compose-compiler = "1.5.10" androidx-compose-material3 = '1.2.1' -androidx-compose-ui = "1.6.3" +androidx-compose-ui = "1.7.0-alpha04" androidx-constraintlayout = "2.1.4" androidx-customview = "1.1.0" androidx-fragment = "1.7.0-alpha10" @@ -51,6 +51,7 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-collection = { module = "androidx.collection:collection", version.ref = "androidx-collection" } androidx-compose-material3 = { module = "androidx.compose.material3:material3-android", version.ref = "androidx-compose-material3" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation-android", version.ref = "androidx-compose-ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" } androidx-concurrent-futures = { module = "androidx.concurrent:concurrent-futures", version.ref = "androidx-concurrent-futures" } diff --git a/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/SampleTests.kt b/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/SampleTests.kt index 286c6bda..182c543d 100644 --- a/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/SampleTests.kt +++ b/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/SampleTests.kt @@ -81,10 +81,6 @@ class SampleTests { val list = CardList("DpadRecyclerView 5") val firstItem = CardItem("5") composeListScreen.scrollTo(list, firstItem) - - composeListScreen.scrollTo(list, CardItem("4")) - - composeListScreen.assertIsNotFocused(list, firstItem) } } diff --git a/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/list/ComposeListScreen.kt b/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/list/ComposeListScreen.kt index b9925d84..dcb2f495 100644 --- a/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/list/ComposeListScreen.kt +++ b/sample/src/androidTest/java/com/rubensousa/dpadrecyclerview/sample/test/list/ComposeListScreen.kt @@ -17,21 +17,18 @@ package com.rubensousa.dpadrecyclerview.sample.test.list import android.view.View -import androidx.compose.ui.test.assertAll +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertAny -import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithText import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.hasFocus import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isNotFocused import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText @@ -39,6 +36,7 @@ import com.rubensousa.dpadrecyclerview.sample.R import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.ItemComposable import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions import org.hamcrest.Matcher +import org.hamcrest.Matchers import org.hamcrest.core.AllOf.allOf class ComposeListScreen(private val composeTestRule: ComposeTestRule) { @@ -78,12 +76,11 @@ class ComposeListScreen(private val composeTestRule: ComposeTestRule) { allOf( withContentDescription(item.text), isDescendantOfA(getCardRecyclerViewMatcher(list.title)), - ViewMatchers.isFocused() + hasFocus() ) ).check(matches(isDisplayed())) - Espresso.onIdle() composeTestRule.onAllNodesWithText(item.text) - .assertAny(hasTestTag(ItemComposable.TEST_TAG_TEXT_FOCUSED)) + .assertAny(SemanticsMatcher.expectValue(ItemComposable.focusedKey, true)) } fun assertIsNotFocused(list: CardList, item: CardItem) { @@ -91,12 +88,11 @@ class ComposeListScreen(private val composeTestRule: ComposeTestRule) { allOf( withContentDescription(item.text), isDescendantOfA(getCardRecyclerViewMatcher(list.title)), - isNotFocused() + Matchers.not(hasFocus()) ) ).check(matches(isDisplayed())) - Espresso.onIdle() composeTestRule.onAllNodesWithText(item.text) - .assertAll(hasTestTag(ItemComposable.TEST_TAG_TEXT_NOT_FOCUSED)) + .assertAny(SemanticsMatcher.expectValue(ItemComposable.focusedKey, false)) } private fun getCardRecyclerViewMatcher(title: String): Matcher { diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeGridAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeGridAdapter.kt index fc2ff77a..6503daf7 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeGridAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeGridAdapter.kt @@ -17,26 +17,32 @@ package com.rubensousa.dpadrecyclerview.sample.ui.screen.compose import android.view.ViewGroup -import androidx.compose.runtime.Composable -import com.rubensousa.dpadrecyclerview.compose.DpadAbstractComposeViewHolder +import com.rubensousa.dpadrecyclerview.compose.DpadComposeFocusViewHolder import com.rubensousa.dpadrecyclerview.sample.ui.model.ListTypes -import com.rubensousa.dpadrecyclerview.sample.ui.widgets.common.ItemAnimator import com.rubensousa.dpadrecyclerview.sample.ui.widgets.common.MutableListAdapter import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.GridItemComposable import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.MutableGridAdapter +import timber.log.Timber -class ComposeGridAdapter : MutableListAdapter( +class ComposeGridAdapter : MutableListAdapter>( MutableGridAdapter.DIFF_CALLBACK ) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): ComposeGridItemViewHolder { - return ComposeGridItemViewHolder(parent, onItemClick = {}) + ): DpadComposeFocusViewHolder { + return DpadComposeFocusViewHolder(parent) { item, isSelected -> + GridItemComposable( + item = item, + onClick = { + Timber.i("Clicked: $item") + } + ) + } } - override fun onBindViewHolder(holder: ComposeGridItemViewHolder, position: Int) { + override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { holder.setItemState(getItem(position)) } @@ -44,32 +50,4 @@ class ComposeGridAdapter : MutableListAdapter Unit - ) : DpadAbstractComposeViewHolder(parent) { - - private val itemAnimator = ItemAnimator(itemView) - - init { - itemView.setOnClickListener { - getItem()?.let(onItemClick) - } - } - - @Composable - override fun Content(item: Int, isFocused: Boolean, isSelected: Boolean) { - GridItemComposable(item, isFocused) - } - - override fun onFocusChanged(hasFocus: Boolean) { - if (hasFocus) { - itemAnimator.startFocusGainAnimation() - } else { - itemAnimator.startFocusLossAnimation() - } - } - - } - } \ No newline at end of file diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeItemAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeItemAdapter.kt index 90be8b4b..b3672d39 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeItemAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/ComposeItemAdapter.kt @@ -19,13 +19,11 @@ package com.rubensousa.dpadrecyclerview.sample.ui.screen.compose import android.view.ViewGroup import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource -import com.rubensousa.dpadrecyclerview.compose.DpadAbstractComposeViewHolder +import com.rubensousa.dpadrecyclerview.compose.DpadComposeFocusViewHolder import com.rubensousa.dpadrecyclerview.sample.R import com.rubensousa.dpadrecyclerview.sample.ui.model.ListTypes -import com.rubensousa.dpadrecyclerview.sample.ui.widgets.common.ItemAnimator import com.rubensousa.dpadrecyclerview.sample.ui.widgets.common.MutableListAdapter import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.ItemComposable import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.MutableGridAdapter @@ -33,56 +31,33 @@ import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.MutableGridAdapter class ComposeItemAdapter( private val onItemClick: (Int) -> Unit = {} -) : MutableListAdapter( - MutableGridAdapter.DIFF_CALLBACK -) { +) : MutableListAdapter>(MutableGridAdapter.DIFF_CALLBACK) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ComposeItemViewHolder { - return ComposeItemViewHolder(parent, onItemClick) - } - - override fun onBindViewHolder(holder: ComposeItemViewHolder, position: Int) { - val item = getItem(position) - holder.setItemState(item) - holder.itemView.contentDescription = item.toString() - } - - override fun getItemViewType(position: Int): Int { - return ListTypes.ITEM - } - - class ComposeItemViewHolder( + override fun onCreateViewHolder( parent: ViewGroup, - onItemClick: (Int) -> Unit - ) : DpadAbstractComposeViewHolder(parent) { - - private val itemAnimator = ItemAnimator(itemView) - - init { - itemView.setOnClickListener { - getItem()?.let(onItemClick) - } - } - - @Composable - override fun Content(item: Int, isFocused: Boolean, isSelected: Boolean) { + viewType: Int + ): DpadComposeFocusViewHolder { + return DpadComposeFocusViewHolder(parent) { item, isSelected -> ItemComposable( modifier = Modifier .width(dimensionResource(id = R.dimen.list_item_width)) .aspectRatio(3 / 4f), item = item, - isFocused = isFocused + onClick = { + onItemClick(item) + } ) } + } - override fun onFocusChanged(hasFocus: Boolean) { - if (hasFocus) { - itemAnimator.startFocusGainAnimation() - } else { - itemAnimator.startFocusLossAnimation() - } - } + override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { + val item = getItem(position) + holder.setItemState(item) + holder.itemView.contentDescription = item.toString() + } + override fun getItemViewType(position: Int): Int { + return ListTypes.ITEM } } diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/NestedComposeListViewHolder.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/NestedComposeListViewHolder.kt index 1c87466b..498c22d3 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/NestedComposeListViewHolder.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/compose/NestedComposeListViewHolder.kt @@ -40,6 +40,9 @@ class NestedComposeListViewHolder( init { recyclerView.setRecycledViewPool(viewPool) + // Compose animations trigger a full layout-pass, + // so disable layout changes while scrolling + recyclerView.setLayoutWhileScrollingEnabled(false) recyclerView.addItemDecoration( DpadLinearSpacingDecoration.create( itemSpacing = itemView.resources.getDimensionPixelOffset( diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt index d446ab85..261d49af 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt @@ -44,12 +44,11 @@ class ComposePlaceholderAdapter( viewType: Int ): DpadComposeViewHolder { return DpadComposeViewHolder( - parent, - composable = { _, _, _ -> - composable() - }, + parent = parent, isFocusable = false - ) + ) { _, _, _ -> + composable() + } } override fun onBindViewHolder(holder: DpadComposeViewHolder, position: Int) { diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt index 5a5b656a..3fef9290 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt @@ -17,6 +17,9 @@ package com.rubensousa.dpadrecyclerview.sample.ui.widgets.item +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio @@ -24,24 +27,46 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.rubensousa.dpadrecyclerview.compose.dpadClickable object ItemComposable { - const val TEST_TAG_TEXT_FOCUSED = "focused_text" - const val TEST_TAG_TEXT_NOT_FOCUSED = "unfocused_text" + val focusedKey = SemanticsPropertyKey("Focused") } @Composable fun ItemComposable( - modifier: Modifier = Modifier, item: Int, isFocused: Boolean + item: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, ) { + var isFocused by remember { mutableStateOf(false) } + val scaleState = animateFloatAsState( + targetValue = if (isFocused) 1.1f else 1.0f, + label = "scale", + animationSpec = tween( + durationMillis = if (isFocused) 350 else 0, + easing = FastOutSlowInEasing + ) + ) val backgroundColor = if (isFocused) { Color.White } else { @@ -54,19 +79,23 @@ fun ItemComposable( } Box( modifier = modifier + .scale(scaleState.value) .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor), + .background(backgroundColor) + .onFocusChanged { focusState -> + isFocused = focusState.hasFocus + } + .focusTarget() + .dpadClickable { + onClick() + }, contentAlignment = Alignment.Center, ) { Text( modifier = Modifier - .testTag( - if (isFocused) { - ItemComposable.TEST_TAG_TEXT_FOCUSED - } else { - ItemComposable.TEST_TAG_TEXT_NOT_FOCUSED - } - ), + .semantics { + set(ItemComposable.focusedKey, isFocused) + }, text = item.toString(), color = textColor, fontSize = 35.sp @@ -75,24 +104,37 @@ fun ItemComposable( } @Composable -fun GridItemComposable(item: Int, isFocused: Boolean) { +fun GridItemComposable( + item: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { ItemComposable( - modifier = Modifier + modifier = modifier .fillMaxWidth() .aspectRatio(3f / 4f), item = item, - isFocused + onClick = onClick ) } @Preview @Composable fun PreviewGridItemComposableFocused() { - GridItemComposable(item = 0, isFocused = true) + val focusRequester = remember { + FocusRequester() + } + GridItemComposable( + modifier = Modifier.focusRequester(focusRequester), + item = 0, + ) + SideEffect { + focusRequester.requestFocus() + } } @Preview @Composable fun PreviewGridItemComposableNotFocused() { - GridItemComposable(item = 0, isFocused = false) + GridItemComposable(item = 0) } \ No newline at end of file