Skip to content

Commit

Permalink
Add support for circular focus in linear layouts
Browse files Browse the repository at this point in the history
  • Loading branch information
rubensousa committed Jun 15, 2024
1 parent a50d76b commit a5f8065
Show file tree
Hide file tree
Showing 13 changed files with 303 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +33,7 @@ internal class DefaultFocusInterceptor(
) : FocusInterceptor {

override fun findFocus(
recyclerView: RecyclerView,
recyclerView: DpadRecyclerView,
focusedView: View,
position: Int,
direction: Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,19 +73,23 @@ 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 {
when (absoluteDirection) {
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
Expand Down
Loading

0 comments on commit a5f8065

Please sign in to comment.