Skip to content

Commit

Permalink
Merge pull request #195 from rubensousa/compose_focus
Browse files Browse the repository at this point in the history
Send focus to composables now that view-interop is fixed
  • Loading branch information
rubensousa committed Mar 16, 2024
2 parents 73a2c25 + 3f7e477 commit b89d9b2
Show file tree
Hide file tree
Showing 27 changed files with 959 additions and 281 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ jobs:
ram-size: 4096M
emulator-options: -no-window -no-snapshot-save -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: |
./scripts/install_test_apks.sh
./scripts/run_instrumented_tests.sh dpadrecyclerview
./scripts/run_instrumented_tests.sh dpadrecyclerview-testing
./scripts/run_instrumented_tests.sh dpadrecyclerview-compose
./scripts/run_instrumented_tests.sh sample
./gradlew uninstallAll
./gradlew --build-cache dpadrecyclerview-compose:connectedDebugAndroidTest
./gradlew --build-cache sample:connectedDebugAndroidTest
./gradlew --build-cache dpadrecyclerview-testing:connectedDebugAndroidTest
./gradlew --build-cache dpadrecyclerview:connectedDebugAndroidTest --info
- name: Upload artifacts
uses: actions/upload-artifact@v3
Expand Down
21 changes: 14 additions & 7 deletions dpadrecyclerview-compose/api/dpadrecyclerview-compose.api
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
public abstract class com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder {
public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensionsKt {
public static final fun dpadClickable (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier;
}

public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder {
public static final field $stable I
public fun <init> (Landroid/view/ViewGroup;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;)V
public synthetic fun <init> (Landroid/view/ViewGroup;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public abstract fun Content (Ljava/lang/Object;ZZLandroidx/compose/runtime/Composer;I)V
public fun <init> (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function4;)V
public synthetic fun <init> (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function4;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getItem ()Ljava/lang/Object;
public fun getSubPositionAlignments ()Ljava/util/List;
public fun onFocusChanged (Z)V
public fun onViewHolderDeselected ()V
public fun onViewHolderSelected ()V
public fun onViewHolderSelectedAndAligned ()V
public final fun setItemState (Ljava/lang/Object;)V
}

public class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder : com/rubensousa/dpadrecyclerview/compose/DpadAbstractComposeViewHolder {
public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder, com/rubensousa/dpadrecyclerview/DpadViewHolder {
public static final field $stable I
public fun <init> (Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function5;)V
public synthetic fun <init> (Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function5;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun Content (Ljava/lang/Object;ZZLandroidx/compose/runtime/Composer;I)V
public final fun getItem ()Ljava/lang/Object;
public fun getSubPositionAlignments ()Ljava/util/List;
public fun onViewHolderDeselected ()V
public fun onViewHolderSelected ()V
public fun onViewHolderSelectedAndAligned ()V
public final fun setItemState (Ljava/lang/Object;)V
}

public final class com/rubensousa/dpadrecyclerview/compose/RecyclerViewCompositionStrategy {
Expand Down
5 changes: 3 additions & 2 deletions dpadrecyclerview-compose/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down Expand Up @@ -57,14 +58,14 @@ dependencies {
implementation libs.androidx.appcompat
implementation libs.androidx.recyclerview
implementation libs.androidx.customview.poolingcontainer
implementation libs.androidx.compose.foundation
implementation libs.androidx.compose.ui
implementation libs.androidx.compose.ui.tooling.preview

// Test dependencies
debugImplementation libs.androidx.test.compose.ui.manifest
debugImplementation libs.androidx.compose.ui.tooling
debugImplementation libs.androidx.compose.ui.tooling.preview
debugImplementation libs.androidx.compose.material3
debugImplementation libs.androidx.customview
androidTestImplementation project(':dpadrecyclerview-testing')
androidTestImplementation project(':dpadrecyclerview-test-fixtures')
androidTestImplementation libs.androidx.test.compose.ui.junit4
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* Copyright 2023 Rúben Sousa
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.rubensousa.dpadrecyclerview.compose

import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers
import com.google.common.truth.Truth.assertThat
import com.rubensousa.dpadrecyclerview.DpadRecyclerView
import com.rubensousa.dpadrecyclerview.ExtraLayoutSpaceStrategy
import com.rubensousa.dpadrecyclerview.testfixtures.recording.ScreenRecorderRule
import com.rubensousa.dpadrecyclerview.testing.KeyEvents
import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions
import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule
import org.junit.Rule
import org.junit.Test

class DpadComposeFocusViewHolderTest {

@get:Rule
val idleTimeoutRule = DisableIdleTimeoutRule()

@get:Rule
val screenRecorderRule = ScreenRecorderRule()

@get:Rule
val composeTestRule = createAndroidComposeRule<ComposeFocusTestActivity>()

@Test
fun testComposeItemsReceiveFocus() {
assertFocus(item = 0, isFocused = true)
assertSelection(item = 0, isSelected = true)

KeyEvents.pressDown()
waitForIdleScroll()

assertFocus(item = 1, isFocused = true)
assertSelection(item = 1, isSelected = true)
}

@Test
fun testComposeFocusChanges() {
composeTestRule.activityRule.scenario.onActivity { activity ->
activity.clearFocus()
}

Espresso.onIdle()
assertFocus(item = 0, isFocused = false)
assertSelection(item = 0, isSelected = true)

composeTestRule.activityRule.scenario.onActivity { activity ->
activity.requestFocus()
}

assertFocus(item = 0, isFocused = true)
assertSelection(item = 0, isSelected = true)
}

@Test
fun testClicksAreDispatched() {
// given
var clicks: List<Int> = emptyList()
composeTestRule.activityRule.scenario.onActivity { activity ->
clicks = activity.getClicks()
}

// when
KeyEvents.click()

// then
assertThat(clicks).isEqualTo(listOf(0))
}

@Test
fun testCompositionIsClearedWhenClearingAdapter() {
val viewHolders = ArrayList<RecyclerView.ViewHolder>()
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<Int> = 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))
}

}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -30,33 +28,32 @@ import androidx.test.espresso.matcher.ViewMatchers
import com.google.common.truth.Truth.assertThat
import com.rubensousa.dpadrecyclerview.DpadRecyclerView
import com.rubensousa.dpadrecyclerview.ExtraLayoutSpaceStrategy
import com.rubensousa.dpadrecyclerview.testfixtures.recording.ScreenRecorderRule
import com.rubensousa.dpadrecyclerview.testing.KeyEvents
import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions
import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule
import org.junit.Rule
import org.junit.Test

class DpadComposeViewHolderTest {

@get:Rule
val composeTestRule = createAndroidComposeRule<TestActivity>()
val idleTimeoutRule = DisableIdleTimeoutRule()

@get:Rule
val screenRecorderRule = ScreenRecorderRule()

@get:Rule
val composeTestRule = createAndroidComposeRule<ViewFocusTestActivity>()

@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)
}
Expand All @@ -71,24 +68,12 @@ class DpadComposeViewHolderTest {
assertFocus(item = 0, isFocused = false)
assertSelection(item = 0, isSelected = true)

assertFocus(item = 1, isFocused = false)
assertSelection(item = 1, isSelected = false)

assertFocus(item = 2, isFocused = false)
assertSelection(item = 2, isSelected = false)

composeTestRule.activityRule.scenario.onActivity { activity ->
activity.requestFocus()
}

assertFocus(item = 0, isFocused = true)
assertSelection(item = 0, isSelected = true)

assertFocus(item = 1, isFocused = false)
assertSelection(item = 1, isSelected = false)

assertFocus(item = 2, isFocused = false)
assertSelection(item = 2, isSelected = false)
}

@Test
Expand All @@ -102,19 +87,17 @@ class DpadComposeViewHolderTest {

@Test
fun testClicksAreDispatched() {
KeyEvents.click()

KeyEvents.pressDown()
waitForIdleScroll()

KeyEvents.click()

// given
var clicks: List<Int> = 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
Expand All @@ -126,8 +109,8 @@ class DpadComposeViewHolderTest {
}

viewHolders.forEach { viewHolder ->
val composeView = viewHolder.itemView as ComposeView
assertThat(composeView.hasComposition).isFalse()
val composeView = viewHolder.itemView as DpadComposeView
assertThat(composeView.hasComposition()).isFalse()
}
composeTestRule.onNodeWithText("0").assertDoesNotExist()
}
Expand Down Expand Up @@ -183,5 +166,3 @@ class DpadComposeViewHolderTest {
}

}


11 changes: 10 additions & 1 deletion dpadrecyclerview-compose/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<application tools:ignore="MissingTvBanner">
<activity
android:name=".TestActivity"
android:name=".ComposeFocusTestActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:exported="true">
<intent-filter>
Expand All @@ -37,6 +37,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ViewFocusTestActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

0 comments on commit b89d9b2

Please sign in to comment.