From a5f80653a90d9a09bcaa44f6adc530f0ea6ae156 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 15 Jun 2024 16:34:37 +0200 Subject: [PATCH 1/3] Add support for circular focus in linear layouts --- .../focus/HorizontalCircularFocusTest.kt | 104 ++++++++++++++++++ .../tests/focus/VerticalCircularFocusTest.kt | 103 +++++++++++++++++ .../layoutmanager/PivotLayoutManager.kt | 2 +- .../focus/CircularFocusInterceptor.kt | 54 ++++++++- .../focus/ContinuousFocusInterceptor.kt | 5 +- .../focus/DefaultFocusInterceptor.kt | 4 +- .../layoutmanager/focus/FocusDirection.kt | 12 ++ .../layoutmanager/focus/FocusDispatcher.kt | 9 +- .../layoutmanager/focus/FocusInterceptor.kt | 4 +- .../layoutmanager/layout/LayoutInfo.kt | 6 + .../layoutmanager/focus/FocusDirectionTest.kt | 13 +++ .../ui/screen/list/ShortListFragment.kt | 4 +- .../sample/ui/screen/main/MainFragment.kt | 7 -- 13 files changed, 303 insertions(+), 24 deletions(-) create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/HorizontalCircularFocusTest.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/VerticalCircularFocusTest.kt diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/HorizontalCircularFocusTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/HorizontalCircularFocusTest.kt new file mode 100644 index 00000000..f0af6680 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/HorizontalCircularFocusTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2022 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.focus + +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.ChildAlignment +import com.rubensousa.dpadrecyclerview.FocusableDirection +import com.rubensousa.dpadrecyclerview.ParentAlignment +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForAdapterUpdate +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 HorizontalCircularFocusTest : DpadRecyclerViewTest() { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + private val numberOfItems = 3 + + override fun getDefaultLayoutConfiguration(): TestLayoutConfiguration { + return TestLayoutConfiguration( + spans = 1, + orientation = RecyclerView.HORIZONTAL, + parentAlignment = ParentAlignment( + edge = ParentAlignment.Edge.MIN_MAX + ), + childAlignment = ChildAlignment(offset = 0) + ) + } + + override fun getDefaultAdapterConfiguration(): TestAdapterConfiguration { + return super.getDefaultAdapterConfiguration().copy( + numberOfItems = numberOfItems, + itemLayoutId = com.rubensousa.dpadrecyclerview.testing.R.layout.dpadrecyclerview_test_item_horizontal + ) + } + + @Before + fun setup() { + launchFragment() + onRecyclerView("Set focusable direction") { recyclerView -> + recyclerView.setFocusableDirection(FocusableDirection.CIRCULAR) + } + } + + @Test + fun testKeyUpMovesToLastPosition() { + // when + KeyEvents.pressLeft() + waitForIdleScrollState() + + // then + assertFocusAndSelection(numberOfItems - 1) + } + + @Test + fun testKeyDownMovesToFirstPosition() { + // when + KeyEvents.pressRight(numberOfItems) + waitForIdleScrollState() + + // then + assertFocusAndSelection(0) + } + + @Test + fun testCircularFocusDoesNotWorkIfLayoutIsFilled() { + // given + mutateAdapter { adapter -> + adapter.submitList(MutableList(10) { it }) + } + waitForAdapterUpdate() + + // when + KeyEvents.pressLeft() + + // then + assertFocusAndSelection(position = 0) + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/VerticalCircularFocusTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/VerticalCircularFocusTest.kt new file mode 100644 index 00000000..248c8080 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/VerticalCircularFocusTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2022 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.focus + +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.ChildAlignment +import com.rubensousa.dpadrecyclerview.FocusableDirection +import com.rubensousa.dpadrecyclerview.ParentAlignment +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForAdapterUpdate +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 VerticalCircularFocusTest : DpadRecyclerViewTest() { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + private val numberOfItems = 3 + + override fun getDefaultLayoutConfiguration(): TestLayoutConfiguration { + return TestLayoutConfiguration( + spans = 1, + orientation = RecyclerView.VERTICAL, + parentAlignment = ParentAlignment( + edge = ParentAlignment.Edge.MIN_MAX + ), + childAlignment = ChildAlignment(offset = 0) + ) + } + + override fun getDefaultAdapterConfiguration(): TestAdapterConfiguration { + return super.getDefaultAdapterConfiguration().copy( + numberOfItems = numberOfItems + ) + } + + @Before + fun setup() { + launchFragment() + onRecyclerView("Set focusable direction") { recyclerView -> + recyclerView.setFocusableDirection(FocusableDirection.CIRCULAR) + } + } + + @Test + fun testKeyUpMovesToLastPosition() { + // when + KeyEvents.pressUp() + waitForIdleScrollState() + + // then + assertFocusAndSelection(numberOfItems - 1) + } + + @Test + fun testKeyDownMovesToFirstPosition() { + // when + KeyEvents.pressDown(numberOfItems) + waitForIdleScrollState() + + // then + assertFocusAndSelection(0) + } + + @Test + fun testCircularFocusDoesNotWorkIfLayoutIsFilled() { + // given + mutateAdapter { adapter -> + adapter.submitList(MutableList(10) { it }) + } + waitForAdapterUpdate() + + // when + KeyEvents.pressUp() + + // then + assertFocusAndSelection(position = 0) + } + +} diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt index f9d5004f..b6f5a237 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt @@ -75,7 +75,7 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager(), this, configuration, layoutInfo, pivotSelector, scroller ) private var hadFocusBeforeLayout = false - private var recyclerView: RecyclerView? = null + private var recyclerView: DpadRecyclerView? = null private var isScrollingFromTouchEvent = false internal var layoutCompletedListener: DpadRecyclerView.OnLayoutCompletedListener? = null diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/CircularFocusInterceptor.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/CircularFocusInterceptor.kt index 7e8394ad..b54dca11 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/CircularFocusInterceptor.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/CircularFocusInterceptor.kt @@ -17,20 +17,19 @@ package com.rubensousa.dpadrecyclerview.layoutmanager.focus import android.view.View -import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.FocusableDirection import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutInfo /** * Implementation for [FocusableDirection.CIRCULAR] - * TODO: Add tests */ internal class CircularFocusInterceptor( private val layoutInfo: LayoutInfo ) : FocusInterceptor { override fun findFocus( - recyclerView: RecyclerView, + recyclerView: DpadRecyclerView, focusedView: View, position: Int, direction: Int @@ -40,10 +39,55 @@ internal class CircularFocusInterceptor( reverseLayout = layoutInfo.shouldReverseLayout(), direction = direction ) ?: return null - return findFocus(position, focusDirection) + return if (recyclerView.getSpanCount() == 1) { + findLinearFocus(position, focusDirection) + } else { + findGridFocus(position, focusDirection) + } + } + + private fun findLinearFocus(position: Int, direction: FocusDirection): View? { + // We only support the main direction or if the layout is not looping + if (direction.isSecondary() || layoutInfo.isLoopingAllowed) { + return null + } + // We only allow circular focus for linear layouts if all the positions are displayed + if (!layoutInfo.hasCreatedFirstItem() || !layoutInfo.hasCreatedLastItem()) { + return null + } + val positionIncrement = layoutInfo.getPositionIncrement( + goingForward = direction == FocusDirection.NEXT_ROW + || direction == FocusDirection.NEXT_COLUMN + ) + val nextPosition = position + positionIncrement + return findNextFocusableView( + fromPosition = when (nextPosition) { + layoutInfo.getItemCount() -> 0 + -1 -> layoutInfo.getItemCount() - 1 + else -> nextPosition + }, + limitPosition = position, + positionIncrement = positionIncrement + ) + } + + private fun findNextFocusableView( + fromPosition: Int, + limitPosition: Int, + positionIncrement: Int + ): View? { + var currentPosition = fromPosition + while (currentPosition != limitPosition) { + val view = layoutInfo.findViewByPosition(currentPosition) + if (view != null && layoutInfo.isViewFocusable(view)) { + return view + } + currentPosition += positionIncrement + } + return null } - private fun findFocus(position: Int, direction: FocusDirection): View? { + private fun findGridFocus(position: Int, direction: FocusDirection): View? { if (direction != FocusDirection.PREVIOUS_COLUMN && direction != FocusDirection.NEXT_COLUMN) { return null } diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/ContinuousFocusInterceptor.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/ContinuousFocusInterceptor.kt index cdc68c52..4f168e6a 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/ContinuousFocusInterceptor.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/ContinuousFocusInterceptor.kt @@ -17,20 +17,19 @@ package com.rubensousa.dpadrecyclerview.layoutmanager.focus import android.view.View -import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.FocusableDirection import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutInfo /** * Implementation for [FocusableDirection.CONTINUOUS] - * TODO: Add tests */ internal class ContinuousFocusInterceptor( private val layoutInfo: LayoutInfo, ) : FocusInterceptor { override fun findFocus( - recyclerView: RecyclerView, + recyclerView: DpadRecyclerView, focusedView: View, position: Int, direction: Int diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/DefaultFocusInterceptor.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/DefaultFocusInterceptor.kt index e3d70597..ca656a9a 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/DefaultFocusInterceptor.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/DefaultFocusInterceptor.kt @@ -18,7 +18,7 @@ package com.rubensousa.dpadrecyclerview.layoutmanager.focus import android.view.FocusFinder import android.view.View -import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.FocusableDirection import com.rubensousa.dpadrecyclerview.layoutmanager.LayoutConfiguration import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutInfo @@ -33,7 +33,7 @@ internal class DefaultFocusInterceptor( ) : FocusInterceptor { override fun findFocus( - recyclerView: RecyclerView, + recyclerView: DpadRecyclerView, focusedView: View, position: Int, direction: Int diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDirection.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDirection.kt index 38bc8b67..0c26d8cf 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDirection.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDirection.kt @@ -24,6 +24,14 @@ internal enum class FocusDirection { PREVIOUS_COLUMN, NEXT_COLUMN; + fun isPrimary(): Boolean { + return this == PREVIOUS_ROW || this == NEXT_ROW + } + + fun isSecondary(): Boolean { + return this == PREVIOUS_COLUMN || this == NEXT_COLUMN + } + fun getScrollSign(reverseLayout: Boolean): Int { if (this == NEXT_COLUMN || this == PREVIOUS_COLUMN) { return 0 @@ -65,9 +73,11 @@ internal enum class FocusDirection { View.FOCUS_LEFT -> { if (reverseLayout) NEXT_COLUMN else PREVIOUS_COLUMN } + View.FOCUS_RIGHT -> { if (reverseLayout) PREVIOUS_COLUMN else NEXT_COLUMN } + else -> null } } else { @@ -75,9 +85,11 @@ internal enum class FocusDirection { View.FOCUS_LEFT -> { if (reverseLayout) NEXT_ROW else PREVIOUS_ROW } + View.FOCUS_RIGHT -> { if (reverseLayout) PREVIOUS_ROW else NEXT_ROW } + View.FOCUS_UP -> if (reverseLayout) NEXT_COLUMN else PREVIOUS_COLUMN View.FOCUS_DOWN -> if (reverseLayout) PREVIOUS_COLUMN else NEXT_COLUMN else -> null diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt index 9ea8ba50..803c06f5 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import android.view.ViewParent import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.LayoutManager +import com.rubensousa.dpadrecyclerview.DpadRecyclerView import com.rubensousa.dpadrecyclerview.FocusableDirection import com.rubensousa.dpadrecyclerview.layoutmanager.LayoutConfiguration import com.rubensousa.dpadrecyclerview.layoutmanager.PivotSelector @@ -121,7 +122,7 @@ internal class FocusDispatcher( } } - fun onInterceptFocusSearch(recyclerView: RecyclerView?, focused: View, direction: Int): View? { + fun onInterceptFocusSearch(recyclerView: DpadRecyclerView?, focused: View, direction: Int): View? { val currentRecyclerView = recyclerView ?: return focused if (!isFocusSearchEnabled(currentRecyclerView)) { @@ -142,8 +143,10 @@ internal class FocusDispatcher( } var newFocusedView: View? = focusInterceptor.findFocus( - recyclerView, focused, - pivotSelector.position, direction + recyclerView = recyclerView, + focusedView = focused, + position = pivotSelector.position, + direction = direction ) // If we found the view using our interceptor, return it immediately diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusInterceptor.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusInterceptor.kt index 2f2c49c0..f28c72c4 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusInterceptor.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusInterceptor.kt @@ -17,7 +17,7 @@ package com.rubensousa.dpadrecyclerview.layoutmanager.focus import android.view.View -import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadRecyclerView internal interface FocusInterceptor { @@ -29,7 +29,7 @@ internal interface FocusInterceptor { * [View.FOCUS_DOWN], [View.FOCUS_FORWARD], [View.FOCUS_BACKWARD] */ fun findFocus( - recyclerView: RecyclerView, + recyclerView: DpadRecyclerView, focusedView: View, position: Int, direction: Int diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/LayoutInfo.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/LayoutInfo.kt index 9f0f2a50..261a7b83 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/LayoutInfo.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/LayoutInfo.kt @@ -354,6 +354,8 @@ internal class LayoutInfo( fun getChildCount() = layout.childCount + fun getItemCount() = layout.itemCount + fun getChildAt(index: Int) = layout.getChildAt(index) /** @@ -381,6 +383,10 @@ internal class LayoutInfo( return view.visibility == View.VISIBLE && (view.isFocusable || view.hasFocusable()) } + fun getPositionIncrement(goingForward: Boolean): Int { + return if (goingForward xor shouldReverseLayout()) 1 else -1 + } + fun didViewHolderStateChange( viewHolder: ViewHolder, pivotPosition: Int, diff --git a/dpadrecyclerview/src/test/java/com/rubensousa/dpadrecyclerview/test/layoutmanager/focus/FocusDirectionTest.kt b/dpadrecyclerview/src/test/java/com/rubensousa/dpadrecyclerview/test/layoutmanager/focus/FocusDirectionTest.kt index 5fcc7715..c8040455 100644 --- a/dpadrecyclerview/src/test/java/com/rubensousa/dpadrecyclerview/test/layoutmanager/focus/FocusDirectionTest.kt +++ b/dpadrecyclerview/src/test/java/com/rubensousa/dpadrecyclerview/test/layoutmanager/focus/FocusDirectionTest.kt @@ -257,4 +257,17 @@ class FocusDirectionTest { ).isEqualTo(1) } + @Test + fun testPrimaryAndSecondaryDirections() { + assertThat(FocusDirection.NEXT_ROW.isPrimary()).isTrue() + assertThat(FocusDirection.PREVIOUS_ROW.isPrimary()).isTrue() + assertThat(FocusDirection.NEXT_COLUMN.isPrimary()).isFalse() + assertThat(FocusDirection.PREVIOUS_COLUMN.isPrimary()).isFalse() + + assertThat(FocusDirection.NEXT_ROW.isSecondary()).isFalse() + assertThat(FocusDirection.PREVIOUS_ROW.isSecondary()).isFalse() + assertThat(FocusDirection.NEXT_COLUMN.isSecondary()).isTrue() + assertThat(FocusDirection.PREVIOUS_COLUMN.isSecondary()).isTrue() + } + } diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/list/ShortListFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/list/ShortListFragment.kt index 450d1833..0bfb6fde 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/list/ShortListFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/list/ShortListFragment.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.DpadViewHolder +import com.rubensousa.dpadrecyclerview.FocusableDirection import com.rubensousa.dpadrecyclerview.ParentAlignment import com.rubensousa.dpadrecyclerview.sample.R import com.rubensousa.dpadrecyclerview.sample.databinding.MainAdapterItemFeatureBinding @@ -30,6 +31,7 @@ class ShortListFragment : Fragment(R.layout.screen_recyclerview) { fraction = 0.5f ) ) + setFocusableDirection(FocusableDirection.CIRCULAR) addItemDecoration( DpadLinearSpacingDecoration.create( itemSpacing = dpToPx(16.dp), @@ -37,7 +39,7 @@ class ShortListFragment : Fragment(R.layout.screen_recyclerview) { ) ) adapter = Adapter( - items = List(5) { i -> + items = List(4) { i -> "Item $i" } ) diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainFragment.kt index be7cfdfa..c06ee88c 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainFragment.kt @@ -21,8 +21,6 @@ import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.recyclerview.widget.RecyclerView -import com.rubensousa.dpadrecyclerview.BuildConfig -import com.rubensousa.dpadrecyclerview.DpadSelectionSnapHelper import com.rubensousa.dpadrecyclerview.OnViewFocusedListener import com.rubensousa.dpadrecyclerview.UnboundViewPool import com.rubensousa.dpadrecyclerview.sample.R @@ -49,11 +47,6 @@ class MainFragment : Fragment(R.layout.screen_main) { scrollState = stateRegistry.getScrollState(), recycledViewPool = UnboundViewPool() ) - // Include this for debug builds if you want to use touch events on the emulator - // or if you need to support touch events on the device (automotive or mobile) - if (BuildConfig.DEBUG) { - DpadSelectionSnapHelper().attachToRecyclerView(recyclerView) - } recyclerView.adapter = adapter recyclerView.requestFocus() recyclerView.addItemDecoration( From 214b2e72171f69aaa6e8c2c31f883f9e1b3db46e Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 15 Jun 2024 22:10:45 +0200 Subject: [PATCH 2/3] Fix focus search for reverse grids --- .../test/tests/focus/ReverseGridFocusTest.kt | 61 +++++++++++++++++++ .../layoutmanager/focus/FocusDispatcher.kt | 18 +++--- 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/ReverseGridFocusTest.kt diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/ReverseGridFocusTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/ReverseGridFocusTest.kt new file mode 100644 index 00000000..e35a0043 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/ReverseGridFocusTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2022 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.focus + +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.ChildAlignment +import com.rubensousa.dpadrecyclerview.ParentAlignment +import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule +import org.junit.Rule +import org.junit.Test + +class ReverseGridFocusTest : DpadRecyclerViewTest() { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + private val spanCount = 5 + + override fun getDefaultLayoutConfiguration(): TestLayoutConfiguration { + return TestLayoutConfiguration( + spans = spanCount, + orientation = RecyclerView.VERTICAL, + parentAlignment = ParentAlignment( + edge = ParentAlignment.Edge.MIN_MAX + ), + reverseLayout = true, + childAlignment = ChildAlignment(offset = 0) + ) + } + + @Test + fun testFocusStartOfRow() { + // given + launchFragment() + + // when + KeyEvents.pressLeft(times = spanCount) + + // then + assertFocusAndSelection(spanCount - 1) + } + +} diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt index 803c06f5..ca87751d 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/FocusDispatcher.kt @@ -122,7 +122,11 @@ internal class FocusDispatcher( } } - fun onInterceptFocusSearch(recyclerView: DpadRecyclerView?, focused: View, direction: Int): View? { + fun onInterceptFocusSearch( + recyclerView: DpadRecyclerView?, + focused: View, + direction: Int + ): View? { val currentRecyclerView = recyclerView ?: return focused if (!isFocusSearchEnabled(currentRecyclerView)) { @@ -434,7 +438,11 @@ internal class FocusDispatcher( if (movement == FocusDirection.NEXT_COLUMN || movement == FocusDirection.PREVIOUS_COLUMN) { return focusNextSpanColumn( focusedPosition = focusedPosition, - next = movement == FocusDirection.NEXT_COLUMN, + next = if (!layoutInfo.shouldReverseLayout()) { + movement == FocusDirection.NEXT_COLUMN + } else { + movement == FocusDirection.PREVIOUS_COLUMN + }, views = views, direction = direction, focusableMode = focusableMode @@ -477,11 +485,7 @@ internal class FocusDispatcher( direction: Int, focusableMode: Int ): Boolean { - val positionIncrement = if (next xor layoutInfo.shouldReverseLayout()) { - 1 - } else { - -1 - } + val positionIncrement = layoutInfo.getPositionIncrement(next) val nextPosition = focusedPosition + positionIncrement if (nextPosition < 0 || nextPosition >= layout.itemCount) { return false From d06ac046061750fa9bbe734b259d63b101a0d5b2 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 15 Jun 2024 23:04:01 +0200 Subject: [PATCH 3/3] Decrease test flakiness --- .../dpadrecyclerview/compose/DpadComposeViewHolderTest.kt | 4 ---- 1 file changed, 4 deletions(-) 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 fda56983..049320f6 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 @@ -35,15 +35,11 @@ import com.rubensousa.dpadrecyclerview.testing.KeyEvents import com.rubensousa.dpadrecyclerview.testing.R import com.rubensousa.dpadrecyclerview.testing.actions.DpadRecyclerViewActions import com.rubensousa.dpadrecyclerview.testing.actions.DpadViewActions -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 screenRecorderRule = ScreenRecorderRule()