diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index 74110e7e..fc5fa8db 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -162,7 +162,6 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle public final fun smoothScrollBy (II)V public final fun smoothScrollBy (IILandroid/view/animation/Interpolator;)V public fun startNestedScroll (II)Z - public fun stopNestedScroll ()V } public abstract interface class com/rubensousa/dpadrecyclerview/DpadRecyclerView$OnKeyInterceptListener { diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestGridFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestGridFragment.kt index e93d9e45..6ee56207 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestGridFragment.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestGridFragment.kt @@ -17,6 +17,7 @@ package com.rubensousa.dpadrecyclerview.test import android.os.Bundle +import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView @@ -71,6 +72,10 @@ open class TestGridFragment : Fragment(R.layout.dpadrecyclerview_test_container) val layoutConfig = args.getParcelable(ARG_LAYOUT_CONFIG)!! val adapterConfig = args.getParcelable(ARG_ADAPTER_CONFIG)!! val recyclerView = view.findViewById(R.id.recyclerView) + val placeHolderView = view.findViewById(R.id.focusPlaceholderView) + placeHolderView.setOnFocusChangeListener { v, hasFocus -> + Log.i(DpadRecyclerView.TAG, "placeholder focus changed: $hasFocus") + } if (layoutConfig.useCustomViewPool) { recyclerView.setRecycledViewPool(viewPool) } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/FocusListenerTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/FocusListenerTest.kt index 1ca95b0f..6629ad55 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/FocusListenerTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/focus/FocusListenerTest.kt @@ -23,8 +23,10 @@ import com.rubensousa.dpadrecyclerview.ChildAlignment import com.rubensousa.dpadrecyclerview.OnViewFocusedListener import com.rubensousa.dpadrecyclerview.ParentAlignment import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertIsNotFocused import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView import com.rubensousa.dpadrecyclerview.test.helpers.selectPosition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest import com.rubensousa.dpadrecyclerview.testing.KeyEvents @@ -155,4 +157,18 @@ class FocusListenerTest : DpadRecyclerViewTest() { assertThat(events.map { it.position }.sorted()).isEqualTo(List(50) { it }) } + @Test + fun testRecyclerViewLosesFocusWhenItLosesContent() { + // when + executeOnFragment { fragment -> + fragment.clearAdapter() + } + waitForCondition("Waiting for 0 children") { recyclerView -> + recyclerView.childCount == 0 + } + + // then + assertIsNotFocused() + } + } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/mutation/AdapterMutationTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/mutation/AdapterMutationTest.kt index 84b1eb75..82bd58e6 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/mutation/AdapterMutationTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/mutation/AdapterMutationTest.kt @@ -27,6 +27,8 @@ import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection import com.rubensousa.dpadrecyclerview.test.helpers.assertItemAtPosition import com.rubensousa.dpadrecyclerview.test.helpers.getRelativeItemViewBounds import com.rubensousa.dpadrecyclerview.test.helpers.selectLastPosition +import com.rubensousa.dpadrecyclerview.test.helpers.selectPosition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForAdapterUpdate import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState import com.rubensousa.dpadrecyclerview.test.tests.AbstractTestAdapter import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest @@ -264,4 +266,24 @@ class AdapterMutationTest : DpadRecyclerViewTest() { Espresso.onIdle() } + + @Test + fun testRemovalOfLargeInterval() { + // given + val startList = List(100) { it } + mutateAdapter { adapter -> + adapter.submitList(startList.toMutableList()) + } + waitForAdapterUpdate() + + // when + selectPosition(99, smooth = true) + mutateAdapter { adapter -> + adapter.submitList(List(25) { 1000 + it }.toMutableList()) + } + waitForAdapterUpdate() + + // then + assertFocusAndSelection(0) + } } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/AccessibilityTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/AccessibilityTest.kt index a0b0fa18..9647b4dd 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/AccessibilityTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/AccessibilityTest.kt @@ -62,13 +62,6 @@ class AccessibilityTest : DpadRecyclerViewTest() { @Test fun testAccessibilityInfo() { - assertThat(accessibilityNodeInfo.actionList.find { - it.id == AccessibilityActionCompat.ACTION_SCROLL_DOWN.id - }).isNotNull() - assertThat(accessibilityNodeInfo.actionList.find { - it.id == AccessibilityActionCompat.ACTION_SCROLL_UP.id - }).isNull() - repeat(10) { KeyEvents.pressDown() } @@ -87,7 +80,10 @@ class AccessibilityTest : DpadRecyclerViewTest() { fun testVerticalScrollForward() { assertFocusAndSelection(position = 0) + // when performAccessibilityAction(AccessibilityAction.ACTION_SCROLL_FORWARD) + + // then assertFocusAndSelection(position = spanCount) } @@ -96,7 +92,10 @@ class AccessibilityTest : DpadRecyclerViewTest() { val position = 20 selectPosition(position) + // when performAccessibilityAction(AccessibilityAction.ACTION_SCROLL_BACKWARD) + + // then assertFocusAndSelection(position = position - spanCount) } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/PendingAlignmentTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/PendingAlignmentTest.kt index f643ccda..0f34b587 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/PendingAlignmentTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/PendingAlignmentTest.kt @@ -94,7 +94,7 @@ class PendingAlignmentTest : DpadRecyclerViewTest() { private fun setupRecyclerView( maxPendingAlignments: Int, maxPendingMoves: Int = 10, - scrollDuration: Int = 4000 + scrollDuration: Int = 2000 ) { onRecyclerView("Setup") { recyclerView -> recyclerView.setSmoothScrollMaxPendingMoves(maxPendingMoves) diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/selection/SelectionTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/selection/SelectionTest.kt index aa94d52a..90831228 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/selection/SelectionTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/selection/SelectionTest.kt @@ -29,7 +29,6 @@ import com.rubensousa.dpadrecyclerview.test.assertions.ViewHolderAlignmentCountA import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusPosition import com.rubensousa.dpadrecyclerview.test.helpers.assertIsFocused -import com.rubensousa.dpadrecyclerview.test.helpers.assertIsNotFocused import com.rubensousa.dpadrecyclerview.test.helpers.assertSelectedPosition import com.rubensousa.dpadrecyclerview.test.helpers.assertViewHolderSelected import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView @@ -102,7 +101,6 @@ class SelectionTest : DpadRecyclerViewTest() { waitForCondition("Waiting for 0 children") { recyclerView -> recyclerView.childCount == 0 } - assertIsNotFocused() assertSelectedPosition(RecyclerView.NO_POSITION) assertThat(getSelectionEvents()).isEqualTo( diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index 85632400..2180e067 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -130,10 +130,10 @@ open class DpadRecyclerView @JvmOverloads constructor( val layout = PivotLayoutManager(properties) layout.setFocusOutAllowed( throughFront = typedArray.getBoolean( - R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutFront, true + R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutFront, false ), throughBack = typedArray.getBoolean( - R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutBack, true + R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutBack, false ) ) layout.setFocusOutSideAllowed( @@ -240,9 +240,6 @@ open class DpadRecyclerView @JvmOverloads constructor( super.requestLayout() } else { hasPendingLayout = true - if (DEBUG) { - Log.i(TAG, "Layout suppressed until scroll is idle") - } } } @@ -376,22 +373,31 @@ open class DpadRecyclerView @JvmOverloads constructor( } final override fun removeView(view: View) { - isRetainingFocus = view.hasFocus() && isFocusable - if (isRetainingFocus) { - requestFocus() - } + preRemoveView(childHasFocus = view.hasFocus()) super.removeView(view) - isRetainingFocus = false + postRemoveView() } final override fun removeViewAt(index: Int) { - val childHasFocus = getChildAt(index)?.hasFocus() ?: false + preRemoveView(childHasFocus = getChildAt(index)?.hasFocus() ?: false) + super.removeViewAt(index) + postRemoveView() + } + + private fun preRemoveView(childHasFocus: Boolean) { isRetainingFocus = childHasFocus && isFocusable + pivotLayoutManager?.setIsRetainingFocus(isRetainingFocus) if (isRetainingFocus) { requestFocus() } - super.removeViewAt(index) + } + + private fun postRemoveView() { + if (isRetainingFocus && childCount > 0 && !hasFocus()) { + requestFocus() + } isRetainingFocus = false + pivotLayoutManager?.setIsRetainingFocus(false) } final override fun setChildDrawingOrderCallback( @@ -433,19 +439,14 @@ open class DpadRecyclerView @JvmOverloads constructor( return result } - override fun stopNestedScroll() { - super.stopNestedScroll() - startedTouchScroll = false - } - override fun onScrollStateChanged(state: Int) { super.onScrollStateChanged(state) if (state == SCROLL_STATE_IDLE) { - startedTouchScroll = false - pivotLayoutManager?.setScrollingFromTouchEvent(false) - if (hasPendingLayout) { + if (hasPendingLayout && !startedTouchScroll) { scheduleLayout() } + startedTouchScroll = false + pivotLayoutManager?.setScrollingFromTouchEvent(false) } else if (startedTouchScroll) { pivotLayoutManager?.setScrollingFromTouchEvent(true) } @@ -460,7 +461,7 @@ open class DpadRecyclerView @JvmOverloads constructor( * while the layout was locked and in that case, we should honor those requests instead * of just performing a full layout */ - post { requestLayout() } + ViewCompat.postOnAnimation(this) { requestLayout() } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { @@ -1320,10 +1321,7 @@ open class DpadRecyclerView @JvmOverloads constructor( private fun removeSelectionForRecycledViewHolders() { addRecyclerListener { holder -> val position = holder.absoluteAdapterPosition - if (holder is DpadViewHolder - && position != NO_POSITION - && position == getSelectedPosition() - ) { + if (position != NO_POSITION && position == getSelectedPosition()) { pivotLayoutManager?.removeCurrentViewHolderSelection() } } diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/LayoutConfiguration.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/LayoutConfiguration.kt index f180e80d..626f5e83 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/LayoutConfiguration.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/LayoutConfiguration.kt @@ -90,8 +90,7 @@ internal class LayoutConfiguration(properties: Properties) { var isFocusSearchDisabled = false private set - var isFocusSearchEnabledDuringAnimations = false - private set + private var isFocusSearchEnabledDuringAnimations = false // Number of items to prefetch when first coming on screen with new data var initialPrefetchItemCount = 4 @@ -141,6 +140,11 @@ internal class LayoutConfiguration(properties: Properties) { setReverseLayout(properties.reverseLayout) } + fun isFocusSearchDisabled(recyclerView: RecyclerView): Boolean { + return isFocusSearchDisabled + || (!isFocusSearchEnabledDuringAnimations && recyclerView.isAnimating) + } + fun setRecycleChildrenOnDetach(recycle: Boolean) { recycleChildrenOnDetach = recycle } 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 49bd98e6..4734f21f 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt @@ -135,7 +135,7 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager() override fun onLayoutCompleted(state: RecyclerView.State) { pivotLayout.onLayoutCompleted(state) if (hadFocusBeforeLayout) { - focusDispatcher.focusSelectedView() + focusDispatcher.focusSelectedView(recyclerView) } pivotSelector.onLayoutCompleted() hadFocusBeforeLayout = false @@ -315,7 +315,9 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager() fun onRequestFocusInDescendants( direction: Int, previouslyFocusedRect: Rect? - ): Boolean = focusDispatcher.onRequestFocusInDescendants(direction, previouslyFocusedRect) + ): Boolean { + return focusDispatcher.onRequestFocusInDescendants(direction, previouslyFocusedRect) + } override fun onRequestChildFocus( parent: RecyclerView, @@ -323,7 +325,8 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager() child: View, focused: View? ): Boolean { - return focusDispatcher.onRequestChildFocus(parent, child, focused) + focusDispatcher.onRequestChildFocus(parent, child, focused) + return true } // Disabled since only this LayoutManager knows how to position views @@ -426,6 +429,10 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager() pivotSelector.removeCurrentViewHolderSelection(clearSelection = isScrollingFromTouchEvent) } + internal fun setIsRetainingFocus(isRetainingFocus: Boolean) { + pivotSelector.isRetainingFocus = isRetainingFocus + } + fun setChildrenDrawingOrderEnabled(enabled: Boolean) { configuration.setChildDrawingOrderEnabled(enabled) } diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt index 0e1f16c6..6f248c2e 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt @@ -45,6 +45,8 @@ internal class PivotSelector( const val OFFSET_DISABLED = Int.MIN_VALUE } + var isRetainingFocus = false + var position: Int = RecyclerView.NO_POSITION private set @@ -100,7 +102,7 @@ internal class PivotSelector( fun focus(view: View) { view.requestFocus() // Exit early if there's no one listening for focus events - if (focusListeners.isEmpty()) { + if (focusListeners.isEmpty() || isRetainingFocus) { return } val currentRecyclerView = recyclerView ?: return @@ -319,6 +321,7 @@ internal class PivotSelector( fun clear() { val hadPivot = position != RecyclerView.NO_POSITION position = RecyclerView.NO_POSITION + subPosition = 0 positionOffset = 0 if (hadPivot) { dispatchViewHolderSelected() @@ -452,6 +455,7 @@ internal class PivotSelector( fun removeCurrentViewHolderSelection(clearSelection: Boolean) { if (clearSelection) { position = 0 + subPosition = 0 positionOffset = 0 } selectedViewHolder?.onViewHolderDeselected() 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 2f35778a..4a943045 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 @@ -110,7 +110,11 @@ internal class FocusDispatcher( } } - fun focusSelectedView() { + fun focusSelectedView(recyclerView: RecyclerView?) { + val currentRecyclerView = recyclerView ?: return + if (!configuration.isFocusSearchDisabled(currentRecyclerView)) { + return + } val view = layoutInfo.findViewByAdapterPosition(pivotSelector.position) ?: return if (layoutInfo.isViewFocusable(view) && !view.hasFocus()) { pivotSelector.focus(view) @@ -183,6 +187,7 @@ internal class FocusDispatcher( newFocusedView = focused } } + FocusDirection.PREVIOUS_ITEM -> { if (isScrolling || !configuration.focusOutFront) { newFocusedView = focused @@ -194,11 +199,13 @@ internal class FocusDispatcher( newFocusedView = focused } } + FocusDirection.NEXT_COLUMN -> { if (isScrolling || !configuration.focusOutSideBack) { newFocusedView = focused } } + FocusDirection.PREVIOUS_COLUMN -> { if (isScrolling || !configuration.focusOutSideFront) { newFocusedView = focused @@ -213,10 +220,7 @@ internal class FocusDispatcher( } private fun isFocusSearchEnabled(recyclerView: RecyclerView): Boolean { - if (configuration.isFocusSearchDisabled) { - return false - } - if (!configuration.isFocusSearchEnabledDuringAnimations && recyclerView.isAnimating) { + if (configuration.isFocusSearchDisabled(recyclerView)) { return false } // Check if this RecyclerView is a Nested RecyclerView and delay focus changes @@ -233,7 +237,10 @@ internal class FocusDispatcher( direction: Int, focusableMode: Int ): Boolean { - if (configuration.isFocusSearchDisabled) { + if (configuration.isFocusSearchDisabled(recyclerView)) { + if (recyclerView.isFocusable) { + views.add(recyclerView) + } return true } if (recyclerView.hasFocus()) { @@ -265,24 +272,23 @@ internal class FocusDispatcher( /** * Request focus to the current pivot if it exists */ - fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean { + fun onRequestFocusInDescendants( + direction: Int, + previouslyFocusedRect: Rect? + ): Boolean { if (configuration.isFocusSearchDisabled) return false val view = layout.findViewByPosition(pivotSelector.position) ?: return false return view.requestFocus(direction, previouslyFocusedRect) } - fun onRequestChildFocus( - recyclerView: RecyclerView, - child: View, - focused: View? - ): Boolean { + fun onRequestChildFocus(recyclerView: RecyclerView, child: View, focused: View?) { if (!isFocusSearchEnabled(recyclerView)) { - return true + return } val childPosition = layoutInfo.getAdapterPositionOf(child) // This could be the last view in DISAPPEARING animation, so ignore immediately if (childPosition == RecyclerView.NO_POSITION) { - return true + return } spanFocusFinder.save(childPosition, configuration.spanSizeLookup) val canScrollToView = !scroller.isSelectionInProgress && !layoutInfo.isLayoutInProgress @@ -292,7 +298,6 @@ internal class FocusDispatcher( ) } pivotSelector.onChildFocused(focused) - return true } private fun addFocusableChildren(