From 98bc8bbf7ee38128b862c9811654239ce3a60aa0 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Wed, 13 Mar 2024 23:37:03 +0100 Subject: [PATCH 01/24] Send focus to composables now that view-interop is fixed --- dpadrecyclerview-compose/build.gradle | 8 ++- .../dpadrecyclerview/compose/TestActivity.kt | 9 ++- .../compose/TestComposable.kt | 47 ++++++++++++-- .../compose/DpadAbstractComposeViewHolder.kt | 42 +++++-------- .../compose/DpadComposeExtensions.kt | 25 ++++++++ .../compose/DpadComposeView.kt | 62 +++++++++++++++++++ .../compose/DpadComposeViewHolder.kt | 20 +++++- gradle/libs.versions.toml | 2 +- .../ui/screen/compose/ComposeGridAdapter.kt | 10 +-- .../ui/screen/compose/ComposeItemAdapter.kt | 9 +-- .../sample/ui/widgets/item/ItemComposable.kt | 60 +++++++++++++++--- 11 files changed, 230 insertions(+), 64 deletions(-) create mode 100644 dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt create mode 100644 dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt diff --git a/dpadrecyclerview-compose/build.gradle b/dpadrecyclerview-compose/build.gradle index 1167a5a7..0a0e33fc 100644 --- a/dpadrecyclerview-compose/build.gradle +++ b/dpadrecyclerview-compose/build.gradle @@ -59,15 +59,19 @@ dependencies { implementation libs.androidx.recyclerview implementation libs.androidx.customview.poolingcontainer 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 + modules { + module("com.google.guava:listenablefuture") { + replacedBy("com.google.guava:guava", "listenablefuture is part of guava") + } + } androidTestUtil libs.androidx.test.services } 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 d47452d9..30a7478e 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 @@ -88,22 +88,21 @@ class TestActivity : AppCompatActivity() { ): DpadComposeViewHolder { return DpadComposeViewHolder( parent, - composable = { item, isFocused, isSelected -> + composable = { item, _, isSelected -> TestComposable( 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 ) } 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..20e559f1 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 @@ -38,10 +48,11 @@ object TestComposable { fun TestComposable( modifier: Modifier = Modifier, item: Int, - isFocused: Boolean, isSelected: Boolean, + onClick: () -> Unit, onDispose: () -> Unit = {}, ) { + var isFocused by remember { mutableStateOf(false) } val backgroundColor = if (isFocused) { Color.White } else if (isSelected) { @@ -51,7 +62,14 @@ fun TestComposable( } Box( modifier = modifier - .background(backgroundColor), + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + } + .focusTarget() + .background(backgroundColor) + .clickable { + onClick() + }, contentAlignment = Alignment.Center, ) { Text( @@ -61,7 +79,7 @@ fun TestComposable( }, text = item.toString(), style = MaterialTheme.typography.headlineLarge, - color = if(isFocused) { + color = if (isFocused) { Color.Black } else { Color.White @@ -78,17 +96,34 @@ fun TestComposable( @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewNormal() { - TestComposable(item = 0, isFocused = false, isSelected = false) + TestComposable( + item = 0, + isSelected = false, + onClick = {} + ) } @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewFocused() { - TestComposable(item = 0, isFocused = true, isSelected = false) + val focusRequester = remember { FocusRequester() } + TestComposable( + 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) + TestComposable( + item = 0, + isSelected = true, + onClick = {} + ) } \ No newline at end of file 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 9173ee9b..a36ed68a 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 @@ -19,7 +19,6 @@ 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 @@ -27,42 +26,33 @@ 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, + dispatchFocusToComposable: Boolean = true, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled -) : RecyclerView.ViewHolder(ComposeView(parent.context)), DpadViewHolder { +) : RecyclerView.ViewHolder(DpadComposeView(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) + val composeView = itemView as DpadComposeView + composeView.apply { + setFocusConfiguration( + isFocusable = isFocusable, + dispatchFocusToComposable = dispatchFocusToComposable + ) + setOnFocusChangeListener { _, hasFocus -> + onFocusChanged(hasFocus) + } + setViewCompositionStrategy(compositionStrategy) + setContent { + itemState.value?.let { item -> + Content(item, composeView.hasFocus(), selectionState.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..c1451722 --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt @@ -0,0 +1,25 @@ +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/DpadComposeView.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt new file mode 100644 index 00000000..ce0ef566 --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt @@ -0,0 +1,62 @@ +package com.rubensousa.dpadrecyclerview.compose + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy + +/** + * A wrapper for [ComposeView] to allow keeping focus inside the view system + */ +class DpadComposeView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + private val composeView = ComposeView(context) + private val focusState = mutableStateOf(false) + private val internalFocusListener = OnFocusChangeListener { v, hasFocus -> + focusState.value = 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) + } + +} 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..35434ea6 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,12 +17,24 @@ package com.rubensousa.dpadrecyclerview.compose import android.view.ViewGroup import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy /** * A basic implementation of [DpadAbstractComposeViewHolder] * that forwards [Content] to [composable] and handles clicks. * + * 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`: * * ```kotlin @@ -45,9 +57,15 @@ open class DpadComposeViewHolder( onClick: ((item: T) -> Unit)? = null, onLongClick: ((item: T) -> Boolean)? = null, isFocusable: Boolean = true, + dispatchFocusToComposable: Boolean = true, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, private val composable: DpadComposable, -) : DpadAbstractComposeViewHolder(parent, isFocusable, compositionStrategy) { +) : DpadAbstractComposeViewHolder( + parent = parent, + isFocusable = isFocusable, + dispatchFocusToComposable = dispatchFocusToComposable, + compositionStrategy = compositionStrategy +) { init { if (onClick != null) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73e4596b..3221a0b0 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" 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..6ab4d746 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 @@ -49,8 +49,6 @@ class ComposeGridAdapter : MutableListAdapter Unit ) : DpadAbstractComposeViewHolder(parent) { - private val itemAnimator = ItemAnimator(itemView) - init { itemView.setOnClickListener { getItem()?.let(onItemClick) @@ -59,15 +57,11 @@ class ComposeGridAdapter : MutableListAdapter Unit ) : DpadAbstractComposeViewHolder(parent) { - private val itemAnimator = ItemAnimator(itemView) - init { itemView.setOnClickListener { getItem()?.let(onItemClick) @@ -71,16 +69,11 @@ class ComposeItemAdapter( .width(dimensionResource(id = R.dimen.list_item_width)) .aspectRatio(3 / 4f), item = item, - isFocused = isFocused ) } override fun onFocusChanged(hasFocus: Boolean) { - if (hasFocus) { - itemAnimator.startFocusGainAnimation() - } else { - itemAnimator.startFocusLossAnimation() - } + } } 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..a5927fe2 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,14 +27,25 @@ 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.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" @@ -40,8 +54,19 @@ object ItemComposable { @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,8 +79,16 @@ 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( @@ -75,24 +108,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 From 24a0b8f7fdac97ae8e7983a87db580624e0f76df Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 01:14:07 +0100 Subject: [PATCH 02/24] Allow postponing layout requests during scrolling events --- dpadrecyclerview/api/dpadrecyclerview.api | 2 ++ .../dpadrecyclerview/DpadRecyclerView.kt | 30 +++++++++++++++++++ .../ui/screen/compose/ComposeItemAdapter.kt | 1 - .../compose/NestedComposeListViewHolder.kt | 3 ++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index 62c25577..74c7c055 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -53,6 +53,7 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle protected final fun dispatchGenericFocusedEvent (Landroid/view/MotionEvent;)Z public final fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z public fun dispatchTouchEvent (Landroid/view/MotionEvent;)Z + public final fun enableLayoutChangesWhileScrolling (Z)V public final fun enableMaxEdgeFading (Z)V public final fun enableMinEdgeFading (Z)V public final fun findFirstCompletelyVisibleItemPosition ()I @@ -100,6 +101,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 diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index 909a0128..087e63d8 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 enableLayoutChangesWhileScrolling = 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 (enableLayoutChangesWhileScrolling || 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 + * + * [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 enableLayoutChangesWhileScrolling(enabled: Boolean) { + enableLayoutChangesWhileScrolling = enabled + } + @VisibleForTesting internal fun detachFromWindow() { onDetachedFromWindow() 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 ad150bf5..d1fafa44 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 @@ -25,7 +25,6 @@ import androidx.compose.ui.res.dimensionResource import com.rubensousa.dpadrecyclerview.compose.DpadAbstractComposeViewHolder 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 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..e95a2f28 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.enableLayoutChangesWhileScrolling(false) recyclerView.addItemDecoration( DpadLinearSpacingDecoration.create( itemSpacing = itemView.resources.getDimensionPixelOffset( From 3e1db05d258f49c330edbdd941895438f3e74c28 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 02:04:31 +0100 Subject: [PATCH 03/24] Add ViewHolder that sends focus down to composables --- .../api/dpadrecyclerview-compose.api | 56 +++++- dpadrecyclerview-compose/build.gradle | 1 + .../compose/DpadComposeFocusViewHolderTest.kt | 184 ++++++++++++++++++ .../compose/DpadComposeViewHolderTest.kt | 24 +-- .../src/debug/AndroidManifest.xml | 11 +- .../compose/ComposeFocusTestActivity.kt | 116 +++++++++++ .../compose/TestComposable.kt | 47 ++++- ...stActivity.kt => ViewFocusTestActivity.kt} | 13 +- .../compose/DpadComposeExtensions.kt | 16 ++ .../compose/DpadComposeFocusViewHolder.kt | 96 +++++++++ .../compose/DpadComposeView.kt | 20 +- .../compose/DpadComposeViewHolder.kt | 57 ++++-- gradle/libs.versions.toml | 1 + .../ui/screen/compose/ComposeGridAdapter.kt | 42 ++-- .../ui/screen/compose/ComposeItemAdapter.kt | 51 ++--- .../common/ComposePlaceholderAdapter.kt | 2 +- 16 files changed, 626 insertions(+), 111 deletions(-) create mode 100644 dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt create mode 100644 dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt rename dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/{TestActivity.kt => ViewFocusTestActivity.kt} (93%) create mode 100644 dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index be534b34..ed1e0d50 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -1,7 +1,21 @@ +public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeFocusViewHolderKt { + public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeFocusViewHolderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function4; +} + +public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeViewHolderKt { + public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeViewHolderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function5; + public fun ()V + public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function5; +} + public abstract class com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder : 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 fun (Landroid/view/ViewGroup;ZZLandroidx/compose/ui/platform/ViewCompositionStrategy;)V + public synthetic fun (Landroid/view/ViewGroup;ZZLandroidx/compose/ui/platform/ViewCompositionStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public abstract fun Content (Ljava/lang/Object;ZZLandroidx/compose/runtime/Composer;I)V public final fun getItem ()Ljava/lang/Object; public fun getSubPositionAlignments ()Ljava/util/List; @@ -12,11 +26,47 @@ public abstract class com/rubensousa/dpadrecyclerview/compose/DpadAbstractCompos 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/DpadComposeExtensionsKt { + public static final fun dpadClickable (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; +} + +public 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;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 fun Content (Ljava/lang/Object;ZLandroidx/compose/runtime/Composer;I)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 final class com/rubensousa/dpadrecyclerview/compose/DpadComposeView : android/widget/FrameLayout { + public static final field $stable I + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V + public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun setContent (Lkotlin/jvm/functions/Function2;)V + public final fun setFocusConfiguration (ZZ)V + public fun setOnFocusChangeListener (Landroid/view/View$OnFocusChangeListener;)V + public final fun setViewCompositionStrategy (Landroidx/compose/ui/platform/ViewCompositionStrategy;)V +} + +public 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 onFocusChanged (Z)V + 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 9adecab5..1b3fd8e9 100644 --- a/dpadrecyclerview-compose/build.gradle +++ b/dpadrecyclerview-compose/build.gradle @@ -57,6 +57,7 @@ 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 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..d814362e --- /dev/null +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt @@ -0,0 +1,184 @@ +/* + * 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.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions +import org.junit.Rule +import org.junit.Test + +class DpadComposeFocusViewHolderTest { + + @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) + } + + @Test + fun testComposeFocusChanges() { + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.clearFocus() + } + + Espresso.onIdle() + 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 + fun testNextComposeItemsReceiveFocus() { + KeyEvents.pressDown() + waitForIdleScroll() + + assertFocus(item = 0, isFocused = false) + assertFocus(item = 1, isFocused = true) + } + + @Test + fun testClicksAreDispatched() { + KeyEvents.click() + + KeyEvents.pressDown() + waitForIdleScroll() + + KeyEvents.click() + + var clicks: List = emptyList() + composeTestRule.activityRule.scenario.onActivity { activity -> + clicks = activity.getClicks() + } + + assertThat(clicks).isEqualTo(listOf(0, 1)) + } + + @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..902acb64 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 @@ -38,7 +36,7 @@ import org.junit.Test class DpadComposeViewHolderTest { @get:Rule - val composeTestRule = createAndroidComposeRule() + val composeTestRule = createAndroidComposeRule() @Test fun testComposeItemsReceiveFocus() { @@ -71,24 +69,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 @@ -126,8 +112,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 +169,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/ComposeFocusTestActivity.kt b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt new file mode 100644 index 00000000..6a50d082 --- /dev/null +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt @@ -0,0 +1,116 @@ +/* + * 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 ComposeFocusTestActivity : 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 + ): DpadComposeFocusViewHolder { + return DpadComposeFocusViewHolder( + parent = parent, + content = { item, isSelected -> + TestComposableFocus( + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + item = item, + isSelected = isSelected, + onClick = { + clicks.add(item) + }, + onDispose = { + onDispose(item) + } + ) + }, + ) + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { + holder.setItemState(items[position]) + } + + } +} \ No newline at end of file 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 20e559f1..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 @@ -46,6 +46,47 @@ object TestComposable { @Composable fun TestComposable( + modifier: Modifier = Modifier, + item: Int, + isFocused: Boolean, + isSelected: Boolean, + onDispose: () -> Unit = {}, +) { + val backgroundColor = if (isFocused) { + Color.White + } else if (isSelected) { + Color.Blue + } else { + Color.Black + } + Box( + modifier = modifier + .background(backgroundColor), + 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() + } + } +} + +@Composable +fun TestComposableFocus( modifier: Modifier = Modifier, item: Int, isSelected: Boolean, @@ -96,7 +137,7 @@ fun TestComposable( @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewNormal() { - TestComposable( + TestComposableFocus( item = 0, isSelected = false, onClick = {} @@ -107,7 +148,7 @@ fun TestComposablePreviewNormal() { @Composable fun TestComposablePreviewFocused() { val focusRequester = remember { FocusRequester() } - TestComposable( + TestComposableFocus( item = 0, modifier = Modifier.focusRequester(focusRequester), isSelected = false, @@ -121,7 +162,7 @@ fun TestComposablePreviewFocused() { @Preview(widthDp = 300, heightDp = 300) @Composable fun TestComposablePreviewSelected() { - TestComposable( + TestComposableFocus( item = 0, isSelected = true, onClick = {} diff --git a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt similarity index 93% rename from dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt rename to dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt index 30a7478e..bcdfdaf8 100644 --- a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/TestActivity.kt +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt @@ -28,7 +28,7 @@ import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.DpadRecyclerView -class TestActivity : AppCompatActivity() { +class ViewFocusTestActivity : AppCompatActivity() { private lateinit var recyclerView: DpadRecyclerView private val clicks = ArrayList() @@ -88,21 +88,22 @@ class TestActivity : AppCompatActivity() { ): DpadComposeViewHolder { return DpadComposeViewHolder( parent, - composable = { item, _, isSelected -> + composable = { item, isFocused, isSelected -> TestComposable( 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 ) } @@ -114,4 +115,4 @@ class TestActivity : AppCompatActivity() { } } -} \ No newline at end of file +} 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 index c1451722..f716f31a 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt @@ -1,3 +1,19 @@ +/* + * 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 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..a3b8a1de --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -0,0 +1,96 @@ +/* + * 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)) + * } + * ``` + */ +open 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 + ) + setOnFocusChangeListener { v, hasFocus -> + onFocusChanged(hasFocus) + } + setViewCompositionStrategy(compositionStrategy) + setContent { + itemState.value?.let { item -> + Content(item, selectionState.value) + } + } + } + } + + @Composable + open fun Content(item: T, isSelected: Boolean) { + content(item, isSelected) + } + + 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/DpadComposeView.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt index ce0ef566..681ca07f 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt @@ -1,3 +1,19 @@ +/* + * 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 @@ -5,7 +21,6 @@ import android.util.AttributeSet import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy @@ -17,9 +32,7 @@ class DpadComposeView @JvmOverloads constructor( ) : FrameLayout(context, attrs) { private val composeView = ComposeView(context) - private val focusState = mutableStateOf(false) private val internalFocusListener = OnFocusChangeListener { v, hasFocus -> - focusState.value = hasFocus focusListener?.onFocusChange(v, hasFocus) } private var focusListener: OnFocusChangeListener? = null @@ -59,4 +72,5 @@ class DpadComposeView @JvmOverloads constructor( 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 35434ea6..e725cc57 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,12 +17,15 @@ 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: @@ -57,17 +60,32 @@ open class DpadComposeViewHolder( onClick: ((item: T) -> Unit)? = null, onLongClick: ((item: T) -> Boolean)? = null, isFocusable: Boolean = true, - dispatchFocusToComposable: Boolean = true, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, - private val composable: DpadComposable, -) : DpadAbstractComposeViewHolder( - parent = parent, - isFocusable = isFocusable, - dispatchFocusToComposable = dispatchFocusToComposable, - compositionStrategy = compositionStrategy -) { + private val composable: DpadComposable = { _, _, _ -> }, +) : 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 + onFocusChanged(hasFocus) + } + setViewCompositionStrategy(compositionStrategy) + setContent { + itemState.value?.let { item -> + Content(item, focusState.value, selectionState.value) + } + } + } if (onClick != null) { itemView.setOnClickListener { getItem()?.let(onClick) @@ -82,10 +100,27 @@ open class DpadComposeViewHolder( } @Composable - override fun Content(item: T, isFocused: Boolean, isSelected: Boolean) { + open fun Content(item: T, isFocused: Boolean, isSelected: Boolean) { composable(item, isFocused, isSelected) } + 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 } typealias DpadComposable = @Composable (T, Boolean, Boolean) -> Unit diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad52309d..2856f5e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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/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 6ab4d746..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,26 +50,4 @@ class ComposeGridAdapter : MutableListAdapter Unit - ) : DpadAbstractComposeViewHolder(parent) { - - init { - itemView.setOnClickListener { - getItem()?.let(onItemClick) - } - } - - @Composable - override fun Content(item: Int, isFocused: Boolean, isSelected: Boolean) { - GridItemComposable(item) - } - - override fun onFocusChanged(hasFocus: Boolean) { - - } - - } - } \ 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 d1fafa44..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,10 +19,9 @@ 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.MutableListAdapter @@ -32,49 +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) { - - 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, + onClick = { + onItemClick(item) + } ) } + } - override fun onFocusChanged(hasFocus: Boolean) { - - } + 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/widgets/common/ComposePlaceholderAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt index d446ab85..88b13358 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,7 +44,7 @@ class ComposePlaceholderAdapter( viewType: Int ): DpadComposeViewHolder { return DpadComposeViewHolder( - parent, + parent = parent, composable = { _, _, _ -> composable() }, From faf7fe7461b1c6b5b756e0b5bbd74a54d11ab898 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 23:21:38 +0100 Subject: [PATCH 04/24] Rename enableLayoutChangesWhileScrolling to setLayoutWhileScrollingEnabled --- .../com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt | 8 ++++---- .../ui/screen/compose/NestedComposeListViewHolder.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index 087e63d8..e97d5ad8 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -71,7 +71,7 @@ open class DpadRecyclerView @JvmOverloads constructor( private var isOverlappingRenderingEnabled = true private var isRetainingFocus = false private var startedTouchScroll = false - private var enableLayoutChangesWhileScrolling = true + private var layoutWhileScrollingEnabled = true private var hasPendingLayout = false private var touchInterceptListener: OnTouchInterceptListener? = null private var smoothScrollByBehavior: SmoothScrollByBehavior? = null @@ -229,7 +229,7 @@ open class DpadRecyclerView @JvmOverloads constructor( } final override fun requestLayout() { - if (enableLayoutChangesWhileScrolling || scrollState == SCROLL_STATE_IDLE) { + if (layoutWhileScrollingEnabled || scrollState == SCROLL_STATE_IDLE) { hasPendingLayout = false super.requestLayout() return @@ -1254,8 +1254,8 @@ open class DpadRecyclerView @JvmOverloads constructor( * or false if they should be postponed until [RecyclerView.SCROLL_STATE_IDLE]. * Default is true. */ - fun enableLayoutChangesWhileScrolling(enabled: Boolean) { - enableLayoutChangesWhileScrolling = enabled + fun setLayoutWhileScrollingEnabled(enabled: Boolean) { + layoutWhileScrollingEnabled = enabled } @VisibleForTesting 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 e95a2f28..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 @@ -42,7 +42,7 @@ class NestedComposeListViewHolder( recyclerView.setRecycledViewPool(viewPool) // Compose animations trigger a full layout-pass, // so disable layout changes while scrolling - recyclerView.enableLayoutChangesWhileScrolling(false) + recyclerView.setLayoutWhileScrollingEnabled(false) recyclerView.addItemDecoration( DpadLinearSpacingDecoration.create( itemSpacing = itemView.resources.getDimensionPixelOffset( From 3ded2de0e125e925886e217560986977b2420d3d Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 23:22:58 +0100 Subject: [PATCH 05/24] Remove unnecessary guava configuration --- dpadrecyclerview-compose/build.gradle | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dpadrecyclerview-compose/build.gradle b/dpadrecyclerview-compose/build.gradle index 1b3fd8e9..1824dc0d 100644 --- a/dpadrecyclerview-compose/build.gradle +++ b/dpadrecyclerview-compose/build.gradle @@ -68,10 +68,5 @@ dependencies { androidTestImplementation project(':dpadrecyclerview-testing') androidTestImplementation project(':dpadrecyclerview-test-fixtures') androidTestImplementation libs.androidx.test.compose.ui.junit4 - modules { - module("com.google.guava:listenablefuture") { - replacedBy("com.google.guava:guava", "listenablefuture is part of guava") - } - } androidTestUtil libs.androidx.test.services } From 4560fe69daa83ff9e3b780ae78b53c6d4ea11c34 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 23:40:42 +0100 Subject: [PATCH 06/24] Add UI tests for layout while scrolling --- .../tests/layout/LayoutWhileScrollingTest.kt | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/LayoutWhileScrollingTest.kt 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..8f8b95e6 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/LayoutWhileScrollingTest.kt @@ -0,0 +1,138 @@ +/* + * 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.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 + ) + ) + } + + @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 -> + 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) + waitForIdleScrollState() + + // then + assertThat(layoutCompleted).isEqualTo(1) + } + + @Test + fun testRequestingLayoutDuringScrollIsNotIgnored() { + // given + var layoutCompleted = 0 + val layoutRequests = 10 + 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) + } + + +} + From 1ebc837abd2da01ae4dcd36dbed043074a6d01a8 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 23:45:55 +0100 Subject: [PATCH 07/24] Add missing api change --- dpadrecyclerview/api/dpadrecyclerview.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index 74c7c055..3c26fb8e 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -53,7 +53,6 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle protected final fun dispatchGenericFocusedEvent (Landroid/view/MotionEvent;)Z public final fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z public fun dispatchTouchEvent (Landroid/view/MotionEvent;)Z - public final fun enableLayoutChangesWhileScrolling (Z)V public final fun enableMaxEdgeFading (Z)V public final fun enableMinEdgeFading (Z)V public final fun findFirstCompletelyVisibleItemPosition ()I @@ -122,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 From 478c0a722da0405b779372e5f316bffccc2eec1c Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Thu, 14 Mar 2024 23:46:04 +0100 Subject: [PATCH 08/24] Delete DpadAbstractComposeViewHolder --- .../api/dpadrecyclerview-compose.api | 14 ---- .../compose/DpadAbstractComposeViewHolder.kt | 81 ------------------- 2 files changed, 95 deletions(-) delete mode 100644 dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index ed1e0d50..d5683696 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -12,20 +12,6 @@ public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$ public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function5; } -public abstract class com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder { - public static final field $stable I - public fun (Landroid/view/ViewGroup;ZZLandroidx/compose/ui/platform/ViewCompositionStrategy;)V - public synthetic fun (Landroid/view/ViewGroup;ZZLandroidx/compose/ui/platform/ViewCompositionStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public abstract 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 onFocusChanged (Z)V - 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/DpadComposeExtensionsKt { public static final fun dpadClickable (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; } 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 a36ed68a..00000000 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder.kt +++ /dev/null @@ -1,81 +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.ViewCompositionStrategy -import androidx.recyclerview.widget.RecyclerView -import com.rubensousa.dpadrecyclerview.DpadViewHolder - -/** - * A ViewHolder that will render a [Composable] in [Content]. - * - * Check the default implementation at [DpadComposeViewHolder] - */ -abstract class DpadAbstractComposeViewHolder( - parent: ViewGroup, - isFocusable: Boolean = true, - dispatchFocusToComposable: Boolean = true, - compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled -) : 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 = isFocusable, - dispatchFocusToComposable = dispatchFocusToComposable - ) - setOnFocusChangeListener { _, hasFocus -> - onFocusChanged(hasFocus) - } - setViewCompositionStrategy(compositionStrategy) - setContent { - itemState.value?.let { item -> - Content(item, composeView.hasFocus(), 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 - -} From a86d7fb2b7e640a053038c68f3c161f4544c6165 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 00:59:56 +0100 Subject: [PATCH 09/24] Collect screen recordings for UI tests --- .github/workflows/pr.yml | 6 +----- dpadrecyclerview-compose/build.gradle | 1 + dpadrecyclerview-testing/build.gradle | 1 + dpadrecyclerview/build.gradle | 1 + 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 57897618..2af2be77 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -116,11 +116,7 @@ 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 --build-cache connectedDebugAndroidTest - name: Upload artifacts uses: actions/upload-artifact@v3 diff --git a/dpadrecyclerview-compose/build.gradle b/dpadrecyclerview-compose/build.gradle index 1824dc0d..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' } 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/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' } From 5ceee817e070151c1e8cafcff2402cb2372e8d66 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 01:15:30 +0100 Subject: [PATCH 10/24] Uninstall previous test apks --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2af2be77..0de33eaa 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -77,7 +77,7 @@ jobs: android: false - name: Compile instrumented tests - run: ./gradlew --build-cache assembleDebugAndroidTest + run: ./gradlew --build-cache assembleDebugAndroidTest uninstallAll # Retrieve the cached emulator snapshot. - name: AVD cache From e7739ef86cf57a50324f001f8d8812bc7c3885a9 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 01:20:32 +0100 Subject: [PATCH 11/24] Move uninstall all to emulator job --- .github/workflows/pr.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0de33eaa..dd475ca4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -77,7 +77,7 @@ jobs: android: false - name: Compile instrumented tests - run: ./gradlew --build-cache assembleDebugAndroidTest uninstallAll + run: ./gradlew --build-cache assembleDebugAndroidTest # Retrieve the cached emulator snapshot. - name: AVD cache @@ -116,6 +116,7 @@ jobs: ram-size: 4096M emulator-options: -no-window -no-snapshot-save -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | + ./gradlew uninstallAll ./gradlew --build-cache connectedDebugAndroidTest - name: Upload artifacts From 5f2a290ab92944564de421a70a6a58b6f46e48e5 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 01:37:09 +0100 Subject: [PATCH 12/24] Add --info to see test output --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dd475ca4..f0ca4d05 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -117,7 +117,7 @@ jobs: emulator-options: -no-window -no-snapshot-save -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | ./gradlew uninstallAll - ./gradlew --build-cache connectedDebugAndroidTest + ./gradlew --build-cache connectedDebugAndroidTest --info - name: Upload artifacts uses: actions/upload-artifact@v3 From 436b3d4c60903a8643a13d8b421f2a14908ef194 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 01:52:01 +0100 Subject: [PATCH 13/24] Wait until scroll state changes to idle to request layout --- .../test/tests/layout/LayoutWhileScrollingTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 8f8b95e6..879cab30 100644 --- 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 @@ -24,6 +24,7 @@ import com.rubensousa.dpadrecyclerview.ParentAlignment 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.waitForCondition import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest import com.rubensousa.dpadrecyclerview.testing.KeyEvents @@ -72,6 +73,9 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { // when selectLastPosition(smooth = true) + waitForCondition("Wait for scroll state change") { recyclerView -> + recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE + } repeat(10) { onRecyclerView("Request layout") { recyclerView -> recyclerView.requestLayout() @@ -133,6 +137,5 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { assertThat(layoutCompleted).isEqualTo(layoutRequests) } - } From 44a4e1d97b7e0a00035192b7b185fb0d53e3703e Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 02:31:49 +0100 Subject: [PATCH 14/24] Attempt to decrease test flakiness --- .../compose/DpadComposeFocusViewHolderTest.kt | 19 ++++--------------- .../compose/DpadComposeViewHolderTest.kt | 14 +++++--------- 2 files changed, 9 insertions(+), 24 deletions(-) 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 index d814362e..48b54524 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt @@ -31,11 +31,15 @@ import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.ExtraLayoutSpaceStrategy 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 composeTestRule = createAndroidComposeRule() @@ -47,15 +51,9 @@ class DpadComposeFocusViewHolderTest { 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) } @@ -90,15 +88,6 @@ class DpadComposeFocusViewHolderTest { assertSelection(item = 2, isSelected = false) } - @Test - fun testNextComposeItemsReceiveFocus() { - KeyEvents.pressDown() - waitForIdleScroll() - - assertFocus(item = 0, isFocused = false) - assertFocus(item = 1, isFocused = true) - } - @Test fun testClicksAreDispatched() { KeyEvents.click() 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 902acb64..3ad43563 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 @@ -30,11 +30,16 @@ import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.ExtraLayoutSpaceStrategy 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 idleTimeoutRule = DisableIdleTimeoutRule() + + @get:Rule val composeTestRule = createAndroidComposeRule() @@ -43,18 +48,9 @@ class DpadComposeViewHolderTest { 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) } From c37052ddad020818dbad07488446f9a686b703a2 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 02:48:57 +0100 Subject: [PATCH 15/24] Don't request layout if recyclerview is already idle --- .../test/tests/layout/LayoutWhileScrollingTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 879cab30..ea9998cb 100644 --- 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 @@ -78,7 +78,9 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { } repeat(10) { onRecyclerView("Request layout") { recyclerView -> - recyclerView.requestLayout() + if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { + recyclerView.requestLayout() + } } } waitForIdleScrollState() From 39db07fd75b6d8aae3ba74abce68321236f9ebcb Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 21:41:17 +0100 Subject: [PATCH 16/24] Decrease number of layout requests --- .../test/tests/layout/LayoutWhileScrollingTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ea9998cb..0b94ffe8 100644 --- 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 @@ -76,7 +76,7 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { waitForCondition("Wait for scroll state change") { recyclerView -> recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE } - repeat(10) { + repeat(3) { onRecyclerView("Request layout") { recyclerView -> if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { recyclerView.requestLayout() @@ -115,7 +115,7 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { fun testRequestingLayoutDuringScrollIsNotIgnored() { // given var layoutCompleted = 0 - val layoutRequests = 10 + val layoutRequests = 3 onRecyclerView("Enable layout during scroll") { recyclerView -> recyclerView.setLayoutWhileScrollingEnabled(true) recyclerView.addOnLayoutCompletedListener( From c7476e6df9c3c04663ede35abb68c94d7b6863df Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 21:41:38 +0100 Subject: [PATCH 17/24] Add screen recorder rule to collect failure reports --- .../compose/DpadComposeFocusViewHolderTest.kt | 4 ++++ .../dpadrecyclerview/compose/DpadComposeViewHolderTest.kt | 3 +++ 2 files changed, 7 insertions(+) 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 index 48b54524..c2edb345 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt @@ -29,6 +29,7 @@ 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 @@ -40,6 +41,9 @@ class DpadComposeFocusViewHolderTest { @get:Rule val idleTimeoutRule = DisableIdleTimeoutRule() + @get:Rule + val screenRecorderRule = ScreenRecorderRule() + @get:Rule val composeTestRule = createAndroidComposeRule() 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 3ad43563..85a7c3dc 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 @@ -28,6 +28,7 @@ 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 @@ -39,6 +40,8 @@ class DpadComposeViewHolderTest { @get:Rule val idleTimeoutRule = DisableIdleTimeoutRule() + @get:Rule + val screenRecorderRule = ScreenRecorderRule() @get:Rule val composeTestRule = createAndroidComposeRule() From 4eeb0753907a5c8ac771d644aef81a959a482d89 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 22:54:46 +0100 Subject: [PATCH 18/24] New attempt to deflake tests --- .../compose/DpadComposeFocusViewHolderTest.kt | 29 ++++--------------- .../compose/DpadComposeViewHolderTest.kt | 14 ++++----- 2 files changed, 12 insertions(+), 31 deletions(-) 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 index c2edb345..82557d85 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolderTest.kt @@ -52,9 +52,6 @@ class DpadComposeFocusViewHolderTest { assertFocus(item = 0, isFocused = true) assertSelection(item = 0, isSelected = true) - assertFocus(item = 1, isFocused = false) - assertSelection(item = 1, isSelected = false) - KeyEvents.pressDown() waitForIdleScroll() @@ -72,41 +69,27 @@ class DpadComposeFocusViewHolderTest { 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 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 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 85a7c3dc..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 @@ -87,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 From 4642a3744c3afa547bd7d05b6570c20737acc3a3 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 23:58:50 +0100 Subject: [PATCH 19/24] Decrease number of items in layout --- .../test/tests/layout/LayoutWhileScrollingTest.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index 0b94ffe8..65dd1fc2 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -52,6 +53,10 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { ) } + override fun getDefaultAdapterConfiguration(): TestAdapterConfiguration { + return super.getDefaultAdapterConfiguration().copy(numberOfItems = 100) + } + @Before fun setup() { launchFragment() @@ -76,7 +81,7 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { waitForCondition("Wait for scroll state change") { recyclerView -> recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE } - repeat(3) { + repeat(2) { onRecyclerView("Request layout") { recyclerView -> if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { recyclerView.requestLayout() @@ -115,7 +120,7 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { fun testRequestingLayoutDuringScrollIsNotIgnored() { // given var layoutCompleted = 0 - val layoutRequests = 3 + val layoutRequests = 2 onRecyclerView("Enable layout during scroll") { recyclerView -> recyclerView.setLayoutWhileScrollingEnabled(true) recyclerView.addOnLayoutCompletedListener( From b414a1f56e8ebc58fbdac0b611fcb101b892a250 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Fri, 15 Mar 2024 23:59:39 +0100 Subject: [PATCH 20/24] Add order in UI test execution --- .github/workflows/pr.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f0ca4d05..4381bafb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -117,7 +117,10 @@ jobs: emulator-options: -no-window -no-snapshot-save -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | ./gradlew uninstallAll - ./gradlew --build-cache connectedDebugAndroidTest --info + ./gradlew --build-cache dpadrecyclerview-compose:connectedDebugAndroidTest --info + ./gradlew --build-cache sample:connectedDebugAndroidTest --info + ./gradlew --build-cache dpadrecyclerview-testing:connectedDebugAndroidTest --info + ./gradlew --build-cache dpadrecyclerview:connectedDebugAndroidTest --info - name: Upload artifacts uses: actions/upload-artifact@v3 From 89e54caade2261c641d7e71844ee109a80abdb99 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 16 Mar 2024 01:06:07 +0100 Subject: [PATCH 21/24] Fix sample compose test --- .../sample/test/SampleTests.kt | 4 ---- .../sample/test/list/ComposeListScreen.kt | 18 +++++++----------- .../sample/ui/widgets/item/ItemComposable.kt | 16 ++++++---------- 3 files changed, 13 insertions(+), 25 deletions(-) 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/widgets/item/ItemComposable.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt index a5927fe2..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 @@ -41,15 +41,15 @@ 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 @@ -93,13 +93,9 @@ fun ItemComposable( ) { 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 From 708989cc1e88f8a7285e56405deccf8b23ce0348 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 16 Mar 2024 01:16:25 +0100 Subject: [PATCH 22/24] Clean-up public API of compose extension --- .../api/dpadrecyclerview-compose.api | 33 ++----------------- .../compose/ViewFocusTestActivity.kt | 29 ++++++++-------- .../compose/DpadComposeFocusViewHolder.kt | 18 ++-------- .../compose/DpadComposeView.kt | 3 +- .../compose/DpadComposeViewHolder.kt | 16 ++------- .../common/ComposePlaceholderAdapter.kt | 7 ++-- 6 files changed, 27 insertions(+), 79 deletions(-) diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index d5683696..faaa5b19 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -1,54 +1,25 @@ -public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeFocusViewHolderKt { - public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeFocusViewHolderKt; - public static field lambda-1 Lkotlin/jvm/functions/Function4; - public fun ()V - public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function4; -} - -public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeViewHolderKt { - public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeViewHolderKt; - public static field lambda-1 Lkotlin/jvm/functions/Function5; - public fun ()V - public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function5; -} - 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 class com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder { +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;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 fun Content (Ljava/lang/Object;ZLandroidx/compose/runtime/Composer;I)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 final class com/rubensousa/dpadrecyclerview/compose/DpadComposeView : android/widget/FrameLayout { - public static final field $stable I - public fun (Landroid/content/Context;)V - public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V - public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun setContent (Lkotlin/jvm/functions/Function2;)V - public final fun setFocusConfiguration (ZZ)V - public fun setOnFocusChangeListener (Landroid/view/View$OnFocusChangeListener;)V - public final fun setViewCompositionStrategy (Landroidx/compose/ui/platform/ViewCompositionStrategy;)V -} - -public class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder { +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 onFocusChanged (Z)V public fun onViewHolderDeselected ()V public fun onViewHolderSelected ()V public fun onViewHolderSelectedAndAligned ()V 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 index bcdfdaf8..a71de721 100644 --- a/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt +++ b/dpadrecyclerview-compose/src/debug/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt @@ -87,25 +87,24 @@ class ViewFocusTestActivity : AppCompatActivity() { viewType: Int ): DpadComposeViewHolder { return DpadComposeViewHolder( - parent, - composable = { item, isFocused, isSelected -> - TestComposable( - modifier = Modifier - .fillMaxWidth() - .height(150.dp), - item = item, - isFocused = isFocused, - isSelected = isSelected, - onDispose = { - onDispose(item) - } - ) - }, + 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 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 index a3b8a1de..b667b8a6 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -43,10 +43,10 @@ import com.rubensousa.dpadrecyclerview.DpadViewHolder * } * ``` */ -open class DpadComposeFocusViewHolder( +class DpadComposeFocusViewHolder( parent: ViewGroup, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, - private val content: @Composable (item: T, isSelected: Boolean) -> Unit = { _, _ -> } + private val content: @Composable (item: T, isSelected: Boolean) -> Unit ) : RecyclerView.ViewHolder(DpadComposeView(parent.context)), DpadViewHolder { private val itemState = mutableStateOf(null) @@ -59,23 +59,15 @@ open class DpadComposeFocusViewHolder( isFocusable = true, dispatchFocusToComposable = true ) - setOnFocusChangeListener { v, hasFocus -> - onFocusChanged(hasFocus) - } setViewCompositionStrategy(compositionStrategy) setContent { itemState.value?.let { item -> - Content(item, selectionState.value) + content(item, selectionState.value) } } } } - @Composable - open fun Content(item: T, isSelected: Boolean) { - content(item, isSelected) - } - override fun onViewHolderSelected() { selectionState.value = true } @@ -84,10 +76,6 @@ open class DpadComposeFocusViewHolder( selectionState.value = false } - open fun onFocusChanged(hasFocus: Boolean) { - - } - fun setItemState(item: T?) { itemState.value = item } 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 index 681ca07f..9f91935c 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeView.kt @@ -18,6 +18,7 @@ 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 @@ -27,7 +28,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy /** * A wrapper for [ComposeView] to allow keeping focus inside the view system */ -class DpadComposeView @JvmOverloads constructor( +internal class DpadComposeView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { 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 e725cc57..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 @@ -55,13 +55,13 @@ import com.rubensousa.dpadrecyclerview.DpadViewHolder * } * ``` */ -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 = { _, _, _ -> }, + private val composable: DpadComposable, ) : RecyclerView.ViewHolder(DpadComposeView(parent.context)), DpadViewHolder { private val focusState = mutableStateOf(false) @@ -77,12 +77,11 @@ open class DpadComposeViewHolder( ) setOnFocusChangeListener { _, hasFocus -> focusState.value = hasFocus - onFocusChanged(hasFocus) } setViewCompositionStrategy(compositionStrategy) setContent { itemState.value?.let { item -> - Content(item, focusState.value, selectionState.value) + composable(item, focusState.value, selectionState.value) } } } @@ -99,11 +98,6 @@ open class DpadComposeViewHolder( } } - @Composable - open fun Content(item: T, isFocused: Boolean, isSelected: Boolean) { - composable(item, isFocused, isSelected) - } - override fun onViewHolderSelected() { selectionState.value = true } @@ -112,10 +106,6 @@ open class DpadComposeViewHolder( selectionState.value = false } - open fun onFocusChanged(hasFocus: Boolean) { - - } - fun setItemState(item: T?) { itemState.value = item } 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 88b13358..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 @@ -45,11 +45,10 @@ class ComposePlaceholderAdapter( ): DpadComposeViewHolder { return DpadComposeViewHolder( parent = parent, - composable = { _, _, _ -> - composable() - }, isFocusable = false - ) + ) { _, _, _ -> + composable() + } } override fun onBindViewHolder(holder: DpadComposeViewHolder, position: Int) { From d567215492b524a2d29e6dd05209cc0fa15fe5fb Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 16 Mar 2024 01:54:52 +0100 Subject: [PATCH 23/24] New attempt to deflake test --- .github/workflows/pr.yml | 6 +++--- .../test/tests/layout/LayoutWhileScrollingTest.kt | 13 ++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4381bafb..a4927daf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -117,9 +117,9 @@ jobs: emulator-options: -no-window -no-snapshot-save -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | ./gradlew uninstallAll - ./gradlew --build-cache dpadrecyclerview-compose:connectedDebugAndroidTest --info - ./gradlew --build-cache sample:connectedDebugAndroidTest --info - ./gradlew --build-cache dpadrecyclerview-testing:connectedDebugAndroidTest --info + ./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 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 index 65dd1fc2..af424ec0 100644 --- 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 @@ -25,7 +25,6 @@ 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.waitForCondition import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest import com.rubensousa.dpadrecyclerview.testing.KeyEvents @@ -78,10 +77,7 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { // when selectLastPosition(smooth = true) - waitForCondition("Wait for scroll state change") { recyclerView -> - recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE - } - repeat(2) { + repeat(10) { onRecyclerView("Request layout") { recyclerView -> if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { recyclerView.requestLayout() @@ -110,6 +106,13 @@ class LayoutWhileScrollingTest : DpadRecyclerViewTest() { // when KeyEvents.pressDown(times = 20) + repeat(10) { + onRecyclerView("Request layout") { recyclerView -> + if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { + recyclerView.requestLayout() + } + } + } waitForIdleScrollState() // then From 3f7e477daa7f4fc99e602a71a31a8b9ddcc3303b Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 16 Mar 2024 01:55:36 +0100 Subject: [PATCH 24/24] Fix kdoc --- .../java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index e97d5ad8..b4aac66a 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -1250,7 +1250,7 @@ open class DpadRecyclerView @JvmOverloads constructor( * 1. Compose animations trigger a full unnecessary layout-pass * 2. Content jumping around while scrolling is not ideal sometimes * - * [enabled] - true if layout requests should be possible while scrolling, + * @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. */