diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index fb0c355b..d7e1e3f1 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -12,8 +12,8 @@ public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$ public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function4; } -public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensionsKt { - public static final fun dpadClickable (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; +public final class com/rubensousa/dpadrecyclerview/compose/DpadClickableKt { + public static final fun dpadClickable (Landroidx/compose/ui/Modifier;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)Landroidx/compose/ui/Modifier; } public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder { diff --git a/dpadrecyclerview-compose/src/androidTest/AndroidManifest.xml b/dpadrecyclerview-compose/src/androidTest/AndroidManifest.xml index 1b89c2e9..32859283 100644 --- a/dpadrecyclerview-compose/src/androidTest/AndroidManifest.xml +++ b/dpadrecyclerview-compose/src/androidTest/AndroidManifest.xml @@ -28,7 +28,7 @@ @@ -38,7 +38,7 @@ diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/test/ComposeFocusTestActivity.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt similarity index 87% rename from dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/test/ComposeFocusTestActivity.kt rename to dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt index 6d9ff77d..9156b855 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/test/ComposeFocusTestActivity.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/ComposeFocusTestActivity.kt @@ -14,11 +14,12 @@ * limitations under the License. */ -package com.rubensousa.dpadrecyclerview.compose.test +package com.rubensousa.dpadrecyclerview.compose import android.os.Bundle import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -28,8 +29,7 @@ import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.OnViewFocusedListener -import com.rubensousa.dpadrecyclerview.compose.DpadComposeFocusViewHolder -import com.rubensousa.dpadrecyclerview.compose.TestComposableFocus +import com.rubensousa.dpadrecyclerview.compose.test.R import com.rubensousa.dpadrecyclerview.testfixtures.DpadFocusEvent class ComposeFocusTestActivity : AppCompatActivity() { @@ -37,6 +37,7 @@ class ComposeFocusTestActivity : AppCompatActivity() { private lateinit var recyclerView: DpadRecyclerView private val focusEvents = arrayListOf() private val clicks = ArrayList() + private val longClicks = ArrayList() private val disposals = ArrayList() override fun onCreate(savedInstanceState: Bundle?) { @@ -55,6 +56,11 @@ class ComposeFocusTestActivity : AppCompatActivity() { } ) recyclerView.requestFocus() + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + clearFocus() + } + }) } fun requestFocus() { @@ -69,6 +75,10 @@ class ComposeFocusTestActivity : AppCompatActivity() { return clicks } + fun getLongClicks(): List { + return clicks + } + fun getDisposals(): List { return disposals } @@ -96,7 +106,7 @@ class ComposeFocusTestActivity : AppCompatActivity() { override fun onCreateViewHolder( parent: ViewGroup, - viewType: Int + viewType: Int, ): DpadComposeFocusViewHolder { return DpadComposeFocusViewHolder(parent) { item -> TestComposableFocus( @@ -107,6 +117,9 @@ class ComposeFocusTestActivity : AppCompatActivity() { onClick = { clicks.add(item) }, + onLongClick = { + longClicks.add(item) + }, onDispose = { onDispose(item) } diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadClickableIntegrationTest.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadClickableIntegrationTest.kt new file mode 100644 index 00000000..97cfa048 --- /dev/null +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/DpadClickableIntegrationTest.kt @@ -0,0 +1,77 @@ +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.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.google.common.truth.Truth.assertThat +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.assertions.DpadViewAssertions +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class DpadClickableIntegrationTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setup() { + composeTestRule.waitForIdle() + } + + @Test + fun testPressingBackAfterClickingOnItemClearsFocus() { + // given + KeyEvents.click() + + // when + KeyEvents.back() + + // then + assertFocus(item = 0, isFocused = false) + Espresso.onView( + withId(com.rubensousa.dpadrecyclerview.compose.test.R.id.focusPlaceholder) + ).check(DpadViewAssertions.isFocused()) + } + + @Test + fun testClicksAreDispatched() { + // given + var clicks: List = emptyList() + composeTestRule.activityRule.scenario.onActivity { activity -> + clicks = activity.getClicks() + } + + // when + KeyEvents.click() + + // then + assertThat(clicks).isEqualTo(listOf(0)) + } + + @Test + fun testLongClicksAreDispatched() { + // given + var clicks: List = emptyList() + composeTestRule.activityRule.scenario.onActivity { activity -> + clicks = activity.getLongClicks() + } + + // when + KeyEvents.longClick() + + // then + assertThat(clicks).isEqualTo(listOf(0)) + } + + private fun assertFocus(item: Int, isFocused: Boolean) { + composeTestRule.onNodeWithText(item.toString()).assertIsDisplayed() + .assert(SemanticsMatcher.expectValue(TestComposable.focusedKey, isFocused)) + } + +} 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 e1392979..850339b8 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 @@ -30,7 +30,6 @@ 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.compose.test.ComposeFocusTestActivity import com.rubensousa.dpadrecyclerview.testfixtures.DpadFocusEvent import com.rubensousa.dpadrecyclerview.testing.KeyEvents import com.rubensousa.dpadrecyclerview.testing.R @@ -79,21 +78,6 @@ class DpadComposeFocusViewHolderTest { assertFocus(item = 0, isFocused = true) } - @Test - fun testClicksAreDispatched() { - // given - var clicks: List = emptyList() - composeTestRule.activityRule.scenario.onActivity { activity -> - clicks = activity.getClicks() - } - - // when - KeyEvents.click() - - // then - assertThat(clicks).isEqualTo(listOf(0)) - } - @Test fun testCompositionIsClearedWhenClearingAdapter() { val viewHolders = ArrayList() 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 8859753c..329574d5 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,7 +28,6 @@ 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.compose.test.ViewFocusTestActivity import com.rubensousa.dpadrecyclerview.testfixtures.DpadFocusEvent import com.rubensousa.dpadrecyclerview.testing.KeyEvents import com.rubensousa.dpadrecyclerview.testing.R diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt index eb89a9ce..5da331b9 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt @@ -85,6 +85,7 @@ fun TestComposableFocus( modifier: Modifier = Modifier, item: Int, onClick: () -> Unit, + onLongClick: () -> Unit, onDispose: () -> Unit = {}, ) { var isFocused by remember { mutableStateOf(false) } @@ -100,9 +101,14 @@ fun TestComposableFocus( } .focusable() .background(backgroundColor) - .clickable { - onClick() - }, + .dpadClickable( + onLongClick = { + onLongClick() + }, + onClick = { + onClick() + } + ), contentAlignment = Alignment.Center, ) { Text( @@ -130,7 +136,8 @@ fun TestComposableFocus( fun TestComposablePreviewNormal() { TestComposableFocus( item = 0, - onClick = {} + onClick = {}, + onLongClick = {} ) } @@ -141,7 +148,8 @@ fun TestComposablePreviewFocused() { TestComposableFocus( item = 0, modifier = Modifier.focusRequester(focusRequester), - onClick = {} + onClick = {}, + onLongClick = {} ) SideEffect { focusRequester.requestFocus() diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/test/ViewFocusTestActivity.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt similarity index 95% rename from dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/test/ViewFocusTestActivity.kt rename to dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt index 73f0d287..2970af9d 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/test/ViewFocusTestActivity.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/ViewFocusTestActivity.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.rubensousa.dpadrecyclerview.compose.test +package com.rubensousa.dpadrecyclerview.compose import android.os.Bundle import android.view.View @@ -28,8 +28,7 @@ import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.OnViewFocusedListener -import com.rubensousa.dpadrecyclerview.compose.DpadComposeViewHolder -import com.rubensousa.dpadrecyclerview.compose.TestComposable +import com.rubensousa.dpadrecyclerview.compose.test.R import com.rubensousa.dpadrecyclerview.testfixtures.DpadFocusEvent class ViewFocusTestActivity : AppCompatActivity() { diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadClickable.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadClickable.kt new file mode 100644 index 00000000..34ad0fac --- /dev/null +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadClickable.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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. + */ +/* + * Copyright 2024 Rúben Sousa + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.compose + +import android.content.Context +import android.media.AudioManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.NativeKeyEvent +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import kotlinx.coroutines.launch + +/** + * Similar to [Modifier.clickable], but handles only [AcceptableKeys] + * and triggers a sound effect on click. + * Workaround for: https://issuetracker.google.com/issues/268268856 + */ +@Composable +fun Modifier.dpadClickable( + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onLongClick: (() -> Unit)? = null, + onClick: (() -> Unit)?, +): Modifier { + val context = LocalContext.current + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } + return handleDpadCenter( + enabled = enabled, + interactionSource = interactionSource, + onClick = if (onClick != null) { + { + audioManager?.playSoundEffect(AudioManager.FX_KEY_CLICK) + onClick() + } + } else { + null + }, + onLongClick = if (onLongClick != null) { + { + onLongClick() + } + } else { + null + } + ).focusable(interactionSource = interactionSource) + .semantics(mergeDescendants = true) { + onClick { + onClick?.let { action -> + audioManager?.playSoundEffect(AudioManager.FX_KEY_CLICK) + action() + return@onClick true + } + false + } + onLongClick { + onLongClick?.let { action -> + action() + return@onLongClick true + } + false + } + if (!enabled) { + disabled() + } + } +} + +/** + * This modifier is used to perform some actions when the user clicks the DPAD center button + * + * @param enabled if this is false, the DPAD center event is ignored + * @param interactionSource used to emit [PressInteraction] events + * @param onClick this lambda will be triggered on DPAD center event + * @param onLongClick this lambda will be triggered when DPAD center is long pressed. + */ +private fun Modifier.handleDpadCenter( + enabled: Boolean, + interactionSource: MutableInteractionSource, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) = composed( + inspectorInfo = + debugInspectorInfo { + name = "handleDpadCenter" + properties["enabled"] = enabled + properties["interactionSource"] = interactionSource + properties["onClick"] = onClick + properties["onLongClick"] = onLongClick + } +) { + if (!enabled) return@composed this + + val coroutineScope = rememberCoroutineScope() + val pressInteraction = remember { PressInteraction.Press(Offset.Zero) } + var isLongClick by remember { mutableStateOf(false) } + val isPressed by interactionSource.collectIsPressedAsState() + + this.onFocusChanged { + if (!it.isFocused && isPressed) { + coroutineScope.launch { + interactionSource.emit(PressInteraction.Release(pressInteraction)) + } + } + }.onKeyEvent { keyEvent -> + if (AcceptableKeys.contains(keyEvent.nativeKeyEvent.keyCode)) { + when (keyEvent.nativeKeyEvent.action) { + NativeKeyEvent.ACTION_DOWN -> { + when (keyEvent.nativeKeyEvent.repeatCount) { + 0 -> + coroutineScope.launch { + interactionSource.emit(pressInteraction) + } + + 1 -> + onLongClick?.let { + isLongClick = true + coroutineScope.launch { + interactionSource.emit( + PressInteraction.Release(pressInteraction) + ) + } + it.invoke() + } + } + } + + NativeKeyEvent.ACTION_UP -> { + if (!isLongClick) { + coroutineScope.launch { + interactionSource.emit( + PressInteraction.Release(pressInteraction) + ) + } + onClick?.invoke() + } else { + isLongClick = false + } + } + } + return@onKeyEvent true + } + false + } +} + +private val AcceptableKeys = intArrayOf( + NativeKeyEvent.KEYCODE_DPAD_CENTER, + NativeKeyEvent.KEYCODE_ENTER, + NativeKeyEvent.KEYCODE_NUMPAD_ENTER +) \ 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 deleted file mode 100644 index f716f31a..00000000 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2024 Rúben Sousa - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.rubensousa.dpadrecyclerview.compose - -import android.content.Context -import android.media.AudioManager -import androidx.compose.foundation.clickable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext - -/** - * Similar to [Modifier.clickable], but triggers a sound effect on click. - * Workaround for: https://issuetracker.google.com/issues/268268856 - */ -@Composable -fun Modifier.dpadClickable(action: () -> Unit): Modifier { - val context = LocalContext.current - val audioManager = remember { - context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager - } - return then(Modifier.clickable { - audioManager?.playSoundEffect(AudioManager.FX_KEY_CLICK) - action() - }) -} \ No newline at end of file diff --git a/dpadrecyclerview-testing/api/dpadrecyclerview-testing.api b/dpadrecyclerview-testing/api/dpadrecyclerview-testing.api index 5417d389..c87a6f28 100644 --- a/dpadrecyclerview-testing/api/dpadrecyclerview-testing.api +++ b/dpadrecyclerview-testing/api/dpadrecyclerview-testing.api @@ -4,6 +4,7 @@ public final class com/rubensousa/dpadrecyclerview/testing/KeyEvents { public static synthetic fun back$default (IJILjava/lang/Object;)V public static final fun click (IJ)V public static synthetic fun click$default (IJILjava/lang/Object;)V + public static final fun longClick ()V public static final fun pressDown (IJ)V public static synthetic fun pressDown$default (IJILjava/lang/Object;)V public static final fun pressKey (IIJ)V @@ -77,6 +78,13 @@ public final class com/rubensousa/dpadrecyclerview/testing/matchers/DpadRecycler public final fun withDescendantOfItemViewAt (I)Lorg/hamcrest/Matcher; } +public final class com/rubensousa/dpadrecyclerview/testing/matchers/FocusedRootMatcher : org/hamcrest/TypeSafeMatcher { + public fun ()V + public fun describeTo (Lorg/hamcrest/Description;)V + public fun matchesSafely (Landroidx/test/espresso/Root;)Z + public synthetic fun matchesSafely (Ljava/lang/Object;)Z +} + public final class com/rubensousa/dpadrecyclerview/testing/rules/DisableIdleTimeoutRule : org/junit/rules/TestRule { public fun ()V public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; diff --git a/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadGridFragment.kt b/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadGridFragment.kt index f55a0f1e..8b66217d 100644 --- a/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadGridFragment.kt +++ b/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadGridFragment.kt @@ -34,7 +34,16 @@ class DpadGridFragment : Fragment(R.layout.dpadrecyclerview_test_container), } private val selectionEvents = ArrayList() - private val adapter = DpadTestAdapter() + private val clickEvents = ArrayList() + private val longClickEvents = ArrayList() + private val adapter = DpadTestAdapter( + onClick = { + clickEvents.add(it) + }, + onLongClick = { + longClickEvents.add(it) + } + ) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -55,7 +64,7 @@ class DpadGridFragment : Fragment(R.layout.dpadrecyclerview_test_container), parent: RecyclerView, child: RecyclerView.ViewHolder?, position: Int, - subPosition: Int + subPosition: Int, ) { super.onViewHolderSelected(parent, child, position, subPosition) selectionEvents.add(DpadSelectionEvent(position, subPosition)) @@ -63,6 +72,10 @@ class DpadGridFragment : Fragment(R.layout.dpadrecyclerview_test_container), fun getAdapterSize() = adapter.itemCount + fun getClickEvents() = clickEvents.toList() + + fun getLongClickEvents() = longClickEvents.toList() + fun insertItem() { postAction { adapter.addItem() } } diff --git a/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadTestAdapter.kt b/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadTestAdapter.kt index 7fefa601..c4dd2b36 100644 --- a/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadTestAdapter.kt +++ b/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/DpadTestAdapter.kt @@ -26,10 +26,14 @@ import com.rubensousa.dpadrecyclerview.DpadViewHolder import com.rubensousa.dpadrecyclerview.SubPositionAlignment import java.util.Collections -class DpadTestAdapter(private val showSubPositions: Boolean = false) : +class DpadTestAdapter( + private val showSubPositions: Boolean = false, + private val onClick: (position: Int) -> Unit = {}, + private val onLongClick: (position: Int) -> Unit = {}, +) : RecyclerView.Adapter() { - private var items = ArrayList() + private var items = mutableListOf() init { repeat(1000) { value -> @@ -38,7 +42,7 @@ class DpadTestAdapter(private val showSubPositions: Boolean = false) : } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { - return if (showSubPositions) { + val viewHolder = if (showSubPositions) { SubPositionVH( LayoutInflater.from(parent.context).inflate( R.layout.dpadrecyclerview_test_item_subposition, parent, false @@ -51,6 +55,14 @@ class DpadTestAdapter(private val showSubPositions: Boolean = false) : ) ) } + viewHolder.itemView.setOnClickListener { + onClick(viewHolder.absoluteAdapterPosition) + } + viewHolder.itemView.setOnLongClickListener { + onLongClick(viewHolder.absoluteAdapterPosition) + true + } + return viewHolder } override fun onBindViewHolder(holder: VH, position: Int) { @@ -67,7 +79,7 @@ class DpadTestAdapter(private val showSubPositions: Boolean = false) : } fun removeItem() { - items.removeLast() + items.removeLastOrNull() notifyItemRemoved(items.size) } diff --git a/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/KeyEventsTest.kt b/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/KeyEventsTest.kt index 177bf534..2781c780 100644 --- a/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/KeyEventsTest.kt +++ b/dpadrecyclerview-testing/src/androidTest/java/com/rubensousa/dpadrecyclerview/testing/KeyEventsTest.kt @@ -17,6 +17,7 @@ package com.rubensousa.dpadrecyclerview.testing import androidx.test.espresso.Espresso +import com.google.common.truth.Truth.assertThat import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions import com.rubensousa.dpadrecyclerview.testing.assertions.DpadRecyclerViewAssertions import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule @@ -61,4 +62,39 @@ class KeyEventsTest : RecyclerViewTest() { assert(DpadRecyclerViewAssertions.isFocused(position = 0)) } + + @Test + fun testClick() { + // given + launchGridFragment() + Espresso.onIdle() + + // when + KeyEvents.click() + + // then + var receivedClicks = listOf() + onGridFragment { + receivedClicks = it.getClickEvents() + } + assertThat(receivedClicks).isEqualTo(listOf(0)) + } + + @Test + fun testLongClick() { + // given + launchGridFragment() + Espresso.onIdle() + + // when + KeyEvents.longClick() + + // then + var receivedClicks = listOf() + onGridFragment { + receivedClicks = it.getLongClickEvents() + } + assertThat(receivedClicks).isEqualTo(listOf(0)) + } + } diff --git a/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/KeyEvents.kt b/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/KeyEvents.kt index 2f8e09af..8a72ad35 100644 --- a/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/KeyEvents.kt +++ b/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/KeyEvents.kt @@ -17,9 +17,20 @@ package com.rubensousa.dpadrecyclerview.testing import android.os.SystemClock +import android.view.InputDevice +import android.view.KeyCharacterMap import android.view.KeyEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.test.espresso.Espresso +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice +import com.rubensousa.dpadrecyclerview.testing.matchers.FocusedRootMatcher +import org.hamcrest.Matcher +import org.hamcrest.Matchers import kotlin.math.max object KeyEvents { @@ -44,6 +55,45 @@ object KeyEvents { pressKey(KeyEvent.KEYCODE_DPAD_CENTER, times, delay) } + @JvmStatic + fun longClick() { + Espresso.onView(ViewMatchers.isFocused()) + .inRoot(FocusedRootMatcher()) + .perform(object : ViewAction { + override fun getDescription(): String { + return "Invoking a long click using key events" + } + + override fun getConstraints(): Matcher { + return Matchers.any(View::class.java) + } + + override fun perform(uiController: UiController, view: View) { + // First trigger a down event + var eventTime = SystemClock.uptimeMillis() + val downEvent = KeyEvent( + eventTime, eventTime, KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_DPAD_CENTER, 0, 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, + InputDevice.SOURCE_DPAD + ) + uiController.injectKeyEvent(downEvent) + + // Now wait until the timeout for long press is reached + val longPressTimeout = (ViewConfiguration.getLongPressTimeout() * 1.5f).toLong() + uiController.loopMainThreadForAtLeast(longPressTimeout) + + // Then finally send the up event that will trigger the long click + eventTime = SystemClock.uptimeMillis() + val upEvent = KeyEvent( + eventTime, eventTime, KeyEvent.ACTION_UP, + KeyEvent.KEYCODE_DPAD_CENTER, 0, 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, + InputDevice.SOURCE_DPAD + ) + uiController.injectKeyEvent(upEvent) + } + }) + } + @JvmStatic fun back(times: Int = 1, delay: Long = DEFAULT_KEY_PRESS_DELAY) { pressKey(KeyEvent.KEYCODE_BACK, times, delay) diff --git a/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/matchers/FocusedRootMatcher.kt b/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/matchers/FocusedRootMatcher.kt new file mode 100644 index 00000000..446d91c6 --- /dev/null +++ b/dpadrecyclerview-testing/src/main/java/com/rubensousa/dpadrecyclerview/testing/matchers/FocusedRootMatcher.kt @@ -0,0 +1,17 @@ +package com.rubensousa.dpadrecyclerview.testing.matchers + +import androidx.test.espresso.Root +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher + +class FocusedRootMatcher : TypeSafeMatcher() { + + override fun describeTo(description: Description) { + description.appendText("has focus") + } + + public override fun matchesSafely(root: Root): Boolean { + return root.decorView.hasFocus() + } + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d0bfd86..53edb631 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,29 +1,28 @@ [versions] -android-gradle-plugin = "8.5.0" +# From library +android-gradle-plugin = "8.5.2" +androidx-collection = "1.4.3" +androidx-recyclerview = "1.4.0-beta01" +androidx-poolingcontainer = "1.0.0" androidx-appcompat = "1.7.0" -androidx-collection = "1.4.0" -androidx-concurrent-futures = "1.2.0" +androidx-compose-ui = "1.7.0-rc01" +androidx-test-espresso = '3.6.1' +androidx-test-uiautomator = '2.3.0' +# From sample androidx-compose-material3 = '1.2.1' -androidx-compose-ui = "1.7.0-beta03" androidx-constraintlayout = "2.1.4" -androidx-customview = "1.1.0" -androidx-fragment = "1.7.1" +androidx-fragment = "1.8.2" androidx-interpolator = "1.0.0" androidx-leanback = "1.0.0-alpha03" -androidx-lifecycle = "2.8.1" +androidx-lifecycle = "2.8.4" androidx-navigation = "2.7.7" -androidx-paging = "3.2.1" -androidx-poolingcontainer = "1.0.0" -androidx-recyclerview = "1.4.0-alpha01" -androidx-test-core = '1.5.0' -androidx-test-espresso = '3.5.1' -androidx-test-espressoJunit = '1.1.5' -androidx-test-espressoTruth = '1.5.0' -androidx-test-orchestrator = '1.4.2' -androidx-test-rules = '1.5.0' -androidx-test-runner = '1.5.2' +androidx-paging = "3.3.2" +androidx-test-core = '1.6.1' +androidx-test-espressoJunit = '1.2.1' +androidx-test-espressoTruth = '1.6.0' +androidx-test-rules = '1.6.1' +androidx-test-runner = '1.6.2' androidx-test-services = '1.4.2' -androidx-test-uiautomator = '2.3.0' guava = "33.1.0-android" timber = "5.0.1" @@ -53,9 +52,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androi 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" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } -androidx-customview = { module = "androidx.customview:customview", version.ref = "androidx-customview" } androidx-customview-poolingcontainer = { module = "androidx.customview:customview-poolingcontainer", version.ref = "androidx-poolingcontainer" } androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "androidx-fragment" } androidx-fragment-testing = { module = "androidx.fragment:fragment-testing", version.ref = "androidx-fragment" } diff --git a/versions.gradle b/versions.gradle index 13735bff..cbcee7f3 100644 --- a/versions.gradle +++ b/versions.gradle @@ -1,6 +1,6 @@ def versions = [:] versions.minSdkVersion = 21 versions.targetSdkVersion = 34 -versions.compileSdkVersion = 34 +versions.compileSdkVersion = 35 ext.versions = versions \ No newline at end of file