diff --git a/dpadrecyclerview-test-fixtures/src/main/java/com/rubensousa/dpadrecyclerview/testfixtures/DpadDeselectionEvent.kt b/dpadrecyclerview-test-fixtures/src/main/java/com/rubensousa/dpadrecyclerview/testfixtures/DpadDeselectionEvent.kt new file mode 100644 index 00000000..80e763c5 --- /dev/null +++ b/dpadrecyclerview-test-fixtures/src/main/java/com/rubensousa/dpadrecyclerview/testfixtures/DpadDeselectionEvent.kt @@ -0,0 +1,21 @@ +/* + * 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. + * 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.testfixtures + +import androidx.recyclerview.widget.RecyclerView + +data class DpadDeselectionEvent(val viewHolder: RecyclerView.ViewHolder) \ No newline at end of file diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index fcbe0891..52ade733 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -73,11 +73,13 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V public fun (Landroid/content/Context;Landroid/util/AttributeSet;I)V public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addOnChildLaidOutListener (Lcom/rubensousa/dpadrecyclerview/OnChildLaidOutListener;)V public final fun addOnFocusLostListener (Lcom/rubensousa/dpadrecyclerview/OnFocusLostListener;)V public final fun addOnLayoutCompletedListener (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView$OnLayoutCompletedListener;)V public final fun addOnViewFocusedListener (Lcom/rubensousa/dpadrecyclerview/OnViewFocusedListener;)V public final fun addOnViewHolderSelectedListener (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;)V public final fun addRecyclerListener (Landroidx/recyclerview/widget/RecyclerView$RecyclerListener;)V + public final fun clearOnChildLaidOutListeners ()V public final fun clearOnFocusLostListeners ()V public final fun clearOnLayoutCompletedListeners ()V public final fun clearOnViewFocusedListeners ()V @@ -132,6 +134,7 @@ public class com/rubensousa/dpadrecyclerview/DpadRecyclerView : androidx/recycle public final fun onRtlPropertiesChanged (I)V public fun onScrollStateChanged (I)V protected fun onSizeChanged (IIII)V + public final fun removeOnChildLaidOutListener (Lcom/rubensousa/dpadrecyclerview/OnChildLaidOutListener;)V public final fun removeOnFocusLostListener (Lcom/rubensousa/dpadrecyclerview/OnFocusLostListener;)V public final fun removeOnLayoutCompletedListener (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView$OnLayoutCompletedListener;)V public final fun removeOnViewFocusedListener (Lcom/rubensousa/dpadrecyclerview/OnViewFocusedListener;)V @@ -345,11 +348,13 @@ public abstract interface class com/rubensousa/dpadrecyclerview/OnViewFocusedLis } public abstract interface class com/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener { + public abstract fun onViewHolderDeselected (Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;)V public abstract fun onViewHolderSelected (Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;II)V public abstract fun onViewHolderSelectedAndAligned (Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;II)V } public final class com/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener$DefaultImpls { + public static fun onViewHolderDeselected (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;)V public static fun onViewHolderSelected (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;II)V public static fun onViewHolderSelectedAndAligned (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;II)V } @@ -485,12 +490,14 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/DpadLayoutParam public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager : androidx/recyclerview/widget/RecyclerView$LayoutManager, androidx/recyclerview/widget/ItemTouchHelper$ViewDropHandler, androidx/recyclerview/widget/RecyclerView$SmoothScroller$ScrollVectorProvider { public fun (Landroid/content/Context;Landroid/util/AttributeSet;II)V public fun (Landroidx/recyclerview/widget/RecyclerView$LayoutManager$Properties;)V + public final fun addOnChildLaidOutListener (Lcom/rubensousa/dpadrecyclerview/OnChildLaidOutListener;)V public final fun addOnLayoutCompletedListener (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView$OnLayoutCompletedListener;)V public final fun addOnViewFocusedListener (Lcom/rubensousa/dpadrecyclerview/OnViewFocusedListener;)V public final fun addOnViewHolderSelectedListener (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;)V public fun canScrollHorizontally ()Z public fun canScrollVertically ()Z public fun checkLayoutParams (Landroidx/recyclerview/widget/RecyclerView$LayoutParams;)Z + public final fun clearOnChildLaidOutListeners ()V public final fun clearOnLayoutCompletedListeners ()V public final fun clearOnViewFocusedListeners ()V public final fun clearOnViewHolderSelectedListeners ()V @@ -547,6 +554,7 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutMana public fun onSaveInstanceState ()Landroid/os/Parcelable; public fun performAccessibilityAction (Landroidx/recyclerview/widget/RecyclerView$Recycler;Landroidx/recyclerview/widget/RecyclerView$State;ILandroid/os/Bundle;)Z public fun prepareForDrop (Landroid/view/View;Landroid/view/View;II)V + public final fun removeOnChildLaidOutListener (Lcom/rubensousa/dpadrecyclerview/OnChildLaidOutListener;)V public final fun removeOnLayoutCompletedListener (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView$OnLayoutCompletedListener;)V public final fun removeOnViewFocusedListener (Lcom/rubensousa/dpadrecyclerview/OnViewFocusedListener;)V public final fun removeOnViewHolderSelectedListener (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;)V @@ -571,7 +579,6 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutMana public final fun setLoopDirection (Lcom/rubensousa/dpadrecyclerview/DpadLoopDirection;)V public final fun setMaxPendingAlignments (I)V public final fun setMaxPendingMoves (I)V - public final fun setOnChildLaidOutListener (Lcom/rubensousa/dpadrecyclerview/OnChildLaidOutListener;)V public final fun setOrientation (I)V public final fun setParentAlignment (Lcom/rubensousa/dpadrecyclerview/ParentAlignment;Z)V public final fun setRecycleChildrenOnDetach (Z)V 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 6ee56207..9cd9a650 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestGridFragment.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestGridFragment.kt @@ -28,6 +28,7 @@ import com.rubensousa.dpadrecyclerview.OnViewHolderSelectedListener import com.rubensousa.dpadrecyclerview.UnboundViewPool import com.rubensousa.dpadrecyclerview.ViewHolderTask import com.rubensousa.dpadrecyclerview.test.tests.AbstractTestAdapter +import com.rubensousa.dpadrecyclerview.testfixtures.DpadDeselectionEvent import com.rubensousa.dpadrecyclerview.testfixtures.DpadFocusEvent import com.rubensousa.dpadrecyclerview.testfixtures.DpadSelectionEvent import com.rubensousa.dpadrecyclerview.testing.R @@ -59,6 +60,7 @@ open class TestGridFragment : Fragment(R.layout.dpadrecyclerview_test_container) } private val selectionEvents = ArrayList() + private val deselectionEvents = ArrayList() private val viewHolderSelections = ArrayList() private val viewHolderDeselections = ArrayList() private val tasks = ArrayList() @@ -81,7 +83,7 @@ open class TestGridFragment : Fragment(R.layout.dpadrecyclerview_test_container) } recyclerView.addOnViewHolderSelectedListener(this) recyclerView.addOnViewFocusedListener(this) - recyclerView.setOnChildLaidOutListener(this) + recyclerView.addOnChildLaidOutListener(this) recyclerView.apply { setReverseLayout(layoutConfig.reverseLayout) @@ -127,6 +129,10 @@ open class TestGridFragment : Fragment(R.layout.dpadrecyclerview_test_container) ) } + override fun onViewHolderDeselected(parent: RecyclerView, child: RecyclerView.ViewHolder) { + deselectionEvents.add(DpadDeselectionEvent(child)) + } + override fun onViewHolderSelected( parent: RecyclerView, child: RecyclerView.ViewHolder?, @@ -196,6 +202,8 @@ open class TestGridFragment : Fragment(R.layout.dpadrecyclerview_test_container) fun getSelectionEvents(): List = selectionEvents + fun getDeselectionEvents(): List = deselectionEvents + fun getViewHolderSelections(): List = viewHolderSelections fun getViewHolderDeselections(): List = viewHolderDeselections diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseVerticalTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseVerticalTest.kt index fe1c2cb8..08332a2e 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseVerticalTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseVerticalTest.kt @@ -100,12 +100,14 @@ class ReverseVerticalTest : DpadRecyclerViewTest() { repeat(5) { KeyEvents.pressDown() + waitForIdleScrollState() column.scrollDown() assertChildrenPositions(column) } repeat(5) { KeyEvents.pressUp() + waitForIdleScrollState() column.scrollUp() assertChildrenPositions(column) } 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 77f72a8e..762a033e 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 @@ -36,6 +36,7 @@ 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.testfixtures.DpadDeselectionEvent import com.rubensousa.dpadrecyclerview.testfixtures.DpadSelectionEvent import com.rubensousa.dpadrecyclerview.testing.KeyEvents import com.rubensousa.dpadrecyclerview.testing.R @@ -283,4 +284,22 @@ class SelectionTest : DpadRecyclerViewTest() { assertFocusAndSelection(numberOfItems + 9) } + @Test + fun testDeselectionEventIsSent() { + // given + launchFragment() + + // when + KeyEvents.pressDown() + waitForIdleScrollState() + + // then + var receivedEvents: List = emptyList() + executeOnFragment { fragment -> + receivedEvents = fragment.getDeselectionEvents() + } + assertThat(receivedEvents.size).isEqualTo(1) + assertThat(receivedEvents.first().viewHolder.layoutPosition).isEqualTo(0) + } + } diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index 20270362..50e38901 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -1560,8 +1560,37 @@ open class DpadRecyclerView @JvmOverloads constructor( * * @param listener the listener to be invoked. */ + @Deprecated("Use addOnChildLaidOutListener") fun setOnChildLaidOutListener(listener: OnChildLaidOutListener?) { - requireLayout().setOnChildLaidOutListener(listener) + requireLayout().clearOnLayoutCompletedListeners() + if (listener != null) { + requireLayout().addOnChildLaidOutListener(listener) + } + } + + /** + * Registers a callback to be invoked when an item of this [DpadRecyclerView] has been laid out. + * + * @param listener the listener to be invoked. + */ + fun addOnChildLaidOutListener(listener: OnChildLaidOutListener) { + requireLayout().addOnChildLaidOutListener(listener) + } + + /** + * @see addOnChildLaidOutListener + * + * @param listener the listener to be removed. + */ + fun removeOnChildLaidOutListener(listener: OnChildLaidOutListener) { + requireLayout().removeOnChildLaidOutListener(listener) + } + + /** + * Clears all listeners added by [addOnChildLaidOutListener] + */ + fun clearOnChildLaidOutListeners() { + requireLayout().clearOnChildLaidOutListeners() } /** diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener.kt index 1130bc80..71f9119c 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener.kt @@ -66,4 +66,14 @@ interface OnViewHolderSelectedListener { ) { } + /** + * @param parent The RecyclerView where the deselection happened. + * @param child The ViewHolder within the RecyclerView that was deselected + */ + fun onViewHolderDeselected( + parent: RecyclerView, + child: RecyclerView.ViewHolder, + ) { + } + } 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 d4dc7d7e..0d185dc1 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt @@ -706,8 +706,16 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager(), return layoutInfo.findLastCompletelyVisiblePosition() } - fun setOnChildLaidOutListener(listener: OnChildLaidOutListener?) { - pivotLayout.setOnChildLaidOutListener(listener) + fun addOnChildLaidOutListener(listener: OnChildLaidOutListener) { + pivotLayout.addOnChildLaidOutListener(listener) + } + + fun removeOnChildLaidOutListener(listener: OnChildLaidOutListener) { + pivotLayout.removeOnChildLaidOutListener(listener) + } + + fun clearOnChildLaidOutListeners() { + pivotLayout.clearOnChildLaidOutListeners() } fun addOnLayoutCompletedListener( 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 f6def3a6..deec0392 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt @@ -19,7 +19,6 @@ package com.rubensousa.dpadrecyclerview.layoutmanager import android.util.Log import android.view.View import android.view.ViewParent -import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.recyclerview.widget.RecyclerView.ViewHolder @@ -65,7 +64,7 @@ internal class PivotSelector( private val requestLayoutRunnable = Runnable { layoutManager.requestLayout() } - private var selectedViewHolder: DpadViewHolder? = null + private var selectedViewHolder: ViewHolder? = null private var pendingChildFocus: View? = null fun update(newPosition: Int, newSubPosition: Int = 0): Boolean { @@ -219,10 +218,21 @@ internal class PivotSelector( dispatchViewHolderSelected() dispatchViewHolderSelectedAndAligned() } + /** + * Always update the selected ViewHolder on every layout update + */ + if (position >= 0 && position < layoutInfo.getItemCount()) { + layoutInfo.findViewByAdapterPosition(position)?.let { view -> + layoutInfo.getChildViewHolder(view)?.let { + selectedViewHolder = it + } + } + } } fun getCurrentSubPositions(): Int { - return selectedViewHolder?.getSubPositionAlignments()?.size ?: 0 + val dpadViewHolder = selectedViewHolder as? DpadViewHolder + return dpadViewHolder?.getSubPositionAlignments()?.size ?: 0 } fun setSelectionUpdatePending() { @@ -332,18 +342,20 @@ internal class PivotSelector( } if (viewHolder !== selectedViewHolder) { - selectedViewHolder?.onViewHolderDeselected() + selectedViewHolder?.let { viewHolder -> + if (viewHolder is DpadViewHolder) { + viewHolder.onViewHolderDeselected() + } + selectionListeners.forEach { listener -> + listener.onViewHolderDeselected(recyclerView, viewHolder) + } + } if (viewHolder is DpadViewHolder) { - selectedViewHolder = viewHolder viewHolder.onViewHolderSelected() - } else { - selectedViewHolder = null } } - if (!hasSelectionListeners()) { - return - } + selectedViewHolder = viewHolder if (viewHolder != null) { selectionListeners.forEach { listener -> @@ -393,10 +405,6 @@ internal class PivotSelector( viewHolder.onViewHolderSelectedAndAligned() } - if (!hasSelectionListeners()) { - return - } - if (viewHolder != null) { selectionListeners.forEach { listener -> listener.onViewHolderSelectedAndAligned( @@ -446,7 +454,16 @@ internal class PivotSelector( subPosition = 0 positionOffset = 0 } - selectedViewHolder?.onViewHolderDeselected() + selectedViewHolder?.let { viewHolder -> + if (viewHolder is DpadViewHolder) { + viewHolder.onViewHolderDeselected() + } + recyclerView?.let { + selectionListeners.forEach { listener -> + listener.onViewHolderDeselected(it, viewHolder) + } + } + } selectedViewHolder = null } @@ -456,9 +473,7 @@ internal class PivotSelector( * We might need to resize rows when wrap_content is used, so schedule a new layout request */ private fun scheduleNewLayout(recyclerView: RecyclerView) { - ViewCompat.postOnAnimation(recyclerView, requestLayoutRunnable) + recyclerView.postOnAnimation(requestLayoutRunnable) } - private fun hasSelectionListeners(): Boolean = selectionListeners.isNotEmpty() - } diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/PivotLayout.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/PivotLayout.kt index d6fca4e6..d5ed2768 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/PivotLayout.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/layout/PivotLayout.kt @@ -46,7 +46,7 @@ internal class PivotLayout( } private val childLayoutListener = ChildLayoutListener() - private var layoutListener: OnChildLaidOutListener? = null + private val childLaidOutListeners = mutableListOf() private var structureEngineer = createStructureEngineer() private val layoutCompleteListeners = ArrayList() private var anchor: Int? = null @@ -226,8 +226,16 @@ internal class PivotLayout( structureEngineer.clear() } - fun setOnChildLaidOutListener(listener: OnChildLaidOutListener?) { - layoutListener = listener + fun addOnChildLaidOutListener(listener: OnChildLaidOutListener) { + childLaidOutListeners.add(listener) + } + + fun removeOnChildLaidOutListener(listener: OnChildLaidOutListener) { + childLaidOutListeners.add(listener) + } + + fun clearOnChildLaidOutListeners() { + childLaidOutListeners.clear() } fun addOnLayoutCompletedListener(listener: DpadRecyclerView.OnLayoutCompletedListener) { @@ -364,9 +372,13 @@ internal class PivotLayout( smooth = configuration.isSmoothFocusChangesEnabled ) } - layoutListener?.let { listener -> - val recyclerView = layoutInfo.getRecyclerView() ?: return@let - val viewHolder = layoutInfo.getChildViewHolder(view) ?: return + if (childLaidOutListeners.isEmpty()) { + return + } + val recyclerView = layoutInfo.getRecyclerView() ?: return + val viewHolder = layoutInfo.getChildViewHolder(view) ?: return + + childLaidOutListeners.forEach { listener -> listener.onChildLaidOut(recyclerView, viewHolder) } }