Skip to content

Commit

Permalink
Merge pull request #225 from rubensousa/linear_circular_focus
Browse files Browse the repository at this point in the history
Add support for circular focus in linear layouts
  • Loading branch information
rubensousa authored Jun 15, 2024
2 parents 223d11a + d06ac04 commit f50b101
Show file tree
Hide file tree
Showing 15 changed files with 374 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
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,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)
}

}
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
Loading

0 comments on commit f50b101

Please sign in to comment.