From 8a9ae29a104f3381625918208e35e7aa02010c95 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 00:07:53 +0200 Subject: [PATCH 01/16] Add new sample to reproduce bug --- .../compose/DpadComposeFocusViewHolder.kt | 14 +- .../ui/screen/grid/GridSpanHeaderFragment.kt | 136 ++++++++++++++++++ .../sample/ui/screen/main/MainViewModel.kt | 4 + sample/src/main/res/navigation/nav_graph.xml | 7 + 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt index 9719549d..5ca546f6 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -50,9 +50,9 @@ class DpadComposeFocusViewHolder( ) : RecyclerView.ViewHolder(ComposeView(parent.context)) { private val itemState = mutableStateOf(null) + private val composeView = itemView as ComposeView init { - val composeView = itemView as ComposeView composeView.apply { isFocusable = true isFocusableInTouchMode = true @@ -66,6 +66,18 @@ class DpadComposeFocusViewHolder( } } + fun setFocusable(focusable: Boolean) { + composeView.apply { + isFocusable = focusable + isFocusableInTouchMode = focusable + descendantFocusability = if (focusable) { + ViewGroup.FOCUS_AFTER_DESCENDANTS + } else { + ViewGroup.FOCUS_BLOCK_DESCENDANTS + } + } + } + fun setItemState(item: T?) { itemState.value = item } diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt new file mode 100644 index 00000000..f66e1380 --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt @@ -0,0 +1,136 @@ +/* + * 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.sample.ui.screen.grid + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import com.rubensousa.dpadrecyclerview.DpadSpanSizeLookup +import com.rubensousa.dpadrecyclerview.compose.DpadComposeFocusViewHolder +import com.rubensousa.dpadrecyclerview.sample.R +import com.rubensousa.dpadrecyclerview.sample.databinding.ScreenRecyclerviewBinding +import com.rubensousa.dpadrecyclerview.sample.ui.dpToPx +import com.rubensousa.dpadrecyclerview.sample.ui.model.ListTypes +import com.rubensousa.dpadrecyclerview.sample.ui.viewBinding +import com.rubensousa.dpadrecyclerview.sample.ui.widgets.common.MutableListAdapter +import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.GridItemComposable +import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.MutableGridAdapter +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import timber.log.Timber + +class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { + + private val binding by viewBinding(ScreenRecyclerviewBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val gridAdapter = SpanGridAdapter() + + gridAdapter.submitList( + mutableListOf( + -1, + 1, 2, 3, 4, + -2, + 4, 5, 6, 7, + -3, + 8, 9, 10, 11 + ) + ) + binding.recyclerView.apply { + adapter = gridAdapter + addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = dpToPx(16.dp), + perpendicularItemSpacing = dpToPx(16.dp), + edgeSpacing = dpToPx(48.dp) + ) + ) + setSpanCount(4) + setSpanSizeLookup(object : DpadSpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + val itemViewType = gridAdapter.getItemViewType(position) + return when (itemViewType) { + ListTypes.ITEM -> 1 + else -> getSpanCount() + } + } + }) + requestFocus() + } + } + + class SpanGridAdapter : MutableListAdapter>( + MutableGridAdapter.DIFF_CALLBACK + ) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DpadComposeFocusViewHolder { + return when (viewType) { + ListTypes.ITEM -> { + DpadComposeFocusViewHolder(parent) { item -> + GridItemComposable( + item = item, + onClick = { + Timber.i("Clicked: $item") + } + ) + } + } + + else -> { + DpadComposeFocusViewHolder(parent) { _ -> + Text( + modifier = Modifier.padding( + horizontal = 48.dp, + vertical = 24.dp + ), + text = "Header", + color = Color.White, + style = MaterialTheme.typography.titleLarge, + ) + }.also { + it.setFocusable(false) + } + } + } + } + + override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { + holder.setItemState(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item > 0) { + ListTypes.ITEM + } else { + ListTypes.HEADER + } + } + + } +} + diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt index 9519d3a6..79fd76c5 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt @@ -96,6 +96,10 @@ class MainViewModel : ViewModel() { direction = MainFragmentDirections.openGrid(evenSpans = false), title = "Different span sizes" ), + ScreenDestination( + direction = MainFragmentDirections.openSpanHeader(), + title = "Span Headers" + ), ScreenDestination( direction = MainFragmentDirections.openGrid(reverseLayout = true), title = "Reversed" diff --git a/sample/src/main/res/navigation/nav_graph.xml b/sample/src/main/res/navigation/nav_graph.xml index 6dc83727..7883140a 100644 --- a/sample/src/main/res/navigation/nav_graph.xml +++ b/sample/src/main/res/navigation/nav_graph.xml @@ -80,6 +80,10 @@ android:id="@+id/open_short_list" app:destination="@id/short_list_fragment" /> + + + From 5188809225269f39134d5e5f9bb8bc4e6e6e3bfc Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 00:36:18 +0200 Subject: [PATCH 02/16] Fix first selection not being a focusable view --- .../DpadSelectionSnapHelper.kt | 17 +---- .../layoutmanager/PivotSelector.kt | 20 ++---- .../layoutmanager/layout/PivotLayout.kt | 68 ++++++++++++++++--- 3 files changed, 67 insertions(+), 38 deletions(-) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt index 8d2ba425..53a7994d 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt @@ -18,8 +18,6 @@ package com.rubensousa.dpadrecyclerview import android.util.DisplayMetrics import android.view.View -import android.view.ViewGroup -import androidx.core.view.forEach import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.RecyclerView @@ -79,7 +77,7 @@ class DpadSelectionSnapHelper : LinearSnapHelper() { for (i in 0 until layoutManager.childCount) { val child = layoutManager.getChildAt(i) ?: continue val offset = abs(layoutManager.getScrollOffset(child)) - if (offset < nearestOffset && hasFocusableChild(child)) { + if (offset < nearestOffset && child.hasFocusable()) { nearestOffset = offset nearestView = child } @@ -87,19 +85,6 @@ class DpadSelectionSnapHelper : LinearSnapHelper() { return nearestView } - private fun hasFocusableChild(view: View): Boolean { - if (view.isFocusable || view.isFocusableInTouchMode) { - return true - } - val viewGroup = view as? ViewGroup ?: return false - viewGroup.forEach { child -> - if (hasFocusableChild(child)) { - return true - } - } - return false - } - override fun createScroller( layoutManager: RecyclerView.LayoutManager ): RecyclerView.SmoothScroller? { 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 6f248c2e..a5badcaa 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotSelector.kt @@ -79,6 +79,11 @@ internal class PivotSelector( return position != previousPosition || subPosition != previousSubPosition } + fun reset() { + position = RecyclerView.NO_POSITION + positionOffset = 0 + } + fun consumePendingSelectionChanges(state: RecyclerView.State): Boolean { var consumed = false if (position != RecyclerView.NO_POSITION @@ -209,20 +214,7 @@ internal class PivotSelector( } fun onLayoutChildren(state: RecyclerView.State) { - // Clear the selected position if there are no more items - if (state.itemCount == 0) { - isSelectionUpdatePending = position != RecyclerView.NO_POSITION - position = RecyclerView.NO_POSITION - subPosition = 0 - } else if (position >= state.itemCount) { - position = state.itemCount - 1 - subPosition = 0 - } else if (position == RecyclerView.NO_POSITION && state.itemCount > 0) { - // Make sure the pivot is set to 0 by default whenever we have items - position = 0 - positionOffset = 0 - setSelectionUpdatePending() - } + } fun onLayoutCompleted() { 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 f0185e77..50a9eb3e 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 @@ -51,6 +51,7 @@ internal class PivotLayout( private val layoutCompleteListeners = ArrayList() private val itemChanges = ItemChanges() private var anchor: Int? = null + private var initialSelectionPending = false fun updateStructure() { structureEngineer = createStructureEngineer() @@ -66,6 +67,21 @@ internal class PivotLayout( } fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { + if (state.itemCount == 0) { + // Clear the selected position if there are no more items + if (pivotSelector.position != RecyclerView.NO_POSITION) { + pivotSelector.setSelectionUpdatePending() + } + pivotSelector.reset() + } else if (pivotSelector.position >= state.itemCount) { + pivotSelector.update(newPosition = state.itemCount - 1) + pivotSelector.setSelectionUpdatePending() + } else if (pivotSelector.position == RecyclerView.NO_POSITION && state.itemCount > 0) { + // Make sure the pivot is set to the first focusable position whenever we have items + initialSelectionPending = true + pivotSelector.setSelectionUpdatePending() + } + if (DpadRecyclerView.DEBUG) { Log.i(TAG, "OnLayoutChildren: ${state.asString()}") } @@ -170,6 +186,50 @@ internal class PivotLayout( } } + fun onLayoutCompleted(state: RecyclerView.State) { + if (initialSelectionPending) { + initialSelectionPending = false + updateInitialSelection() + } + itemChanges.reset() + layoutInfo.onLayoutCompleted() + layoutCompleteListeners.forEach { listener -> + listener.onLayoutCompleted(state) + } + } + + /** + * We need to confirm that the initial selection makes sense. + * It can happen that position 0 points to a non focusable position + */ + private fun updateInitialSelection() { + val view = findFirstFocusableView() ?: return + val adapterPosition = layoutInfo.getAdapterPositionOf(view) + if (adapterPosition == RecyclerView.NO_POSITION) { + return + } + if (pivotSelector.position != adapterPosition) { + pivotSelector.update(adapterPosition) + scroller.scrollToSelectedPosition(smooth = false) + } + } + + private fun findFirstFocusableView(): View? { + val childCount = layoutInfo.getChildCount() + for (i in 0 until childCount) { + val actualIndex = if (layoutInfo.shouldReverseLayout()) { + childCount - 1 - i + } else { + i + } + val child = layoutInfo.getChildAt(actualIndex) + if (child != null && child.hasFocusable()) { + return child + } + } + return null + } + fun reset() { structureEngineer.clear() } @@ -190,14 +250,6 @@ internal class PivotLayout( itemChanges.moveItemCount = itemCount } - fun onLayoutCompleted(state: RecyclerView.State) { - itemChanges.reset() - layoutInfo.onLayoutCompleted() - layoutCompleteListeners.forEach { listener -> - listener.onLayoutCompleted(state) - } - } - fun setOnChildLaidOutListener(listener: OnChildLaidOutListener?) { layoutListener = listener } From 591b89c4467efb89b02ee27639f86d94c44a04cf Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 00:56:36 +0200 Subject: [PATCH 03/16] Detach recyclerview correctly --- .../com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt index 53a7994d..8e2d114d 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper.kt @@ -46,6 +46,7 @@ class DpadSelectionSnapHelper : LinearSnapHelper() { currentRecyclerView = recyclerView return } + currentRecyclerView = null if (recyclerView != null) { throw IllegalArgumentException("Only DpadRecyclerView can be used with DpadSnapHelper") } From e98e098d73b67ba311974ef52c374875c4dc2873 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 00:58:27 +0200 Subject: [PATCH 04/16] Fix grids with uneven spans not finding next focusable views --- .../layoutmanager/focus/FocusDispatcher.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) 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 4a943045..574f8056 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 @@ -440,19 +440,23 @@ internal class FocusDispatcher( return false } val edgePosition = layout.getPosition(edgeView) - val nextPosition = spanFocusFinder.findNextSpanPosition( - focusedPosition, - spanSizeLookup = configuration.spanSizeLookup, - forward = movement == FocusDirection.NEXT_ITEM, - edgePosition = edgePosition, - reverseLayout = layoutInfo.shouldReverseLayout() - ) - if (nextPosition == RecyclerView.NO_POSITION) { - return false - } - val newView = layout.findViewByPosition(nextPosition) ?: return false - newView.addFocusables(views, direction, focusableMode) - return true + var nextPosition: Int = focusedPosition + do { + nextPosition = spanFocusFinder.findNextSpanPosition( + focusedPosition = nextPosition, + spanSizeLookup = configuration.spanSizeLookup, + forward = movement == FocusDirection.NEXT_ITEM, + edgePosition = edgePosition, + reverseLayout = layoutInfo.shouldReverseLayout() + ) + val nextView = layout.findViewByPosition(nextPosition) ?: return false + if (nextView.hasFocusable()) { + nextView.addFocusables(views, direction, focusableMode) + return true + } + } while (nextPosition != RecyclerView.NO_POSITION) + return false + } class AddFocusableChildrenRequest(private val layoutInfo: LayoutInfo) { From 6beb1e4644d2ab71468da2a87298cc15dc7874ff Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 01:25:41 +0200 Subject: [PATCH 05/16] Fix getSpanSize being called for positions out of bounds --- .../layoutmanager/focus/SpanFocusFinder.kt | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt index 0ed3e737..34bddbf0 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt @@ -167,15 +167,34 @@ internal class SpanFocusFinder(private val configuration: LayoutConfiguration) { reverseLayout: Boolean ): Int { var currentPos = position - var currentSpan = spanIndex + var currentSpan = getSpanEnd( + lookup = lookup, + position = currentPos, + spanIndex = spanIndex, + spanDir = spanDir + ) val startSpanIndex = if (!reverseLayout) 0 else spanCount - 1 // First step: move to edge of current span group - while (!isPositionOutOfBounds(currentPos + posDir, edgePosition, forward) - && fitsNextInCurrentSpanGroup(lookup, currentSpan, currentPos, spanDir, posDir) + while (!isPositionOutOfBounds( + position = currentPos + posDir, + edgePosition = edgePosition, + forward = forward + ) + && fitsInCurrentSpanGroup( + lookup = lookup, + currentSpanEnd = currentSpan + spanDir, + position = currentPos + posDir, + spanDir = spanDir + ) ) { + currentSpan = getSpanEnd( + lookup = lookup, + position = currentPos, + spanIndex = currentSpan + spanDir, + spanDir = spanDir + ) currentPos += posDir - currentSpan = getNextSpanEnd(lookup, currentSpan, currentPos, spanDir, posDir) } // Move to next span group @@ -203,26 +222,28 @@ internal class SpanFocusFinder(private val configuration: LayoutConfiguration) { return currentPos } - private fun fitsNextInCurrentSpanGroup( + private fun fitsInCurrentSpanGroup( lookup: DpadSpanSizeLookup, - spanIndex: Int, - currentPos: Int, + currentSpanEnd: Int, + position: Int, spanDir: Int, - posDir: Int ): Boolean { - val nextSpanEnd = getNextSpanEnd(lookup, spanIndex, currentPos, spanDir, posDir) - return nextSpanEnd >= 0 && nextSpanEnd <= spanCount - 1 + val nextSpan = getSpanEnd( + lookup = lookup, + position = position, + spanIndex = currentSpanEnd, + spanDir = spanDir + ) + return nextSpan >= 0 && nextSpan <= spanCount - 1 } - private fun getNextSpanEnd( - spanSizeLookup: DpadSpanSizeLookup, + private fun getSpanEnd( + lookup: DpadSpanSizeLookup, + position: Int, spanIndex: Int, - currentPos: Int, - spanDir: Int, - posDir: Int + spanDir: Int ): Int { - val currentSpanEnd = spanIndex + (spanSizeLookup.getSpanSize(currentPos) - 1) * spanDir - return currentSpanEnd + spanSizeLookup.getSpanSize(currentPos + posDir) * spanDir + return spanIndex + (lookup.getSpanSize(position) - 1) * spanDir } private fun isPositionOutOfBounds(position: Int, edgePosition: Int, forward: Boolean): Boolean { From 5f08d6de6c9cd2b6c9b1144d0db248e82ab8d2b2 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 01:44:20 +0200 Subject: [PATCH 06/16] Add check for view being focusable --- .../dpadrecyclerview/layoutmanager/layout/LayoutInfo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a2a37ae6..9f0f2a50 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 @@ -378,7 +378,7 @@ internal class LayoutInfo( } fun isViewFocusable(view: View): Boolean { - return view.visibility == View.VISIBLE && view.hasFocusable() + return view.visibility == View.VISIBLE && (view.isFocusable || view.hasFocusable()) } fun didViewHolderStateChange( From 9f0be514e12473350ded725e9bd25ba87c3a268a Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 01:44:30 +0200 Subject: [PATCH 07/16] Add more headers to test fast scrolling --- .../ui/screen/grid/GridSpanHeaderFragment.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt index f66e1380..3669bc03 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt @@ -47,16 +47,6 @@ class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { super.onViewCreated(view, savedInstanceState) val gridAdapter = SpanGridAdapter() - gridAdapter.submitList( - mutableListOf( - -1, - 1, 2, 3, 4, - -2, - 4, 5, 6, 7, - -3, - 8, 9, 10, 11 - ) - ) binding.recyclerView.apply { adapter = gridAdapter addItemDecoration( @@ -76,8 +66,20 @@ class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { } } }) + + val list = mutableListOf() + val headers = 50 + repeat(headers) { + list.add(-1) + repeat(getSpanCount()) { + list.add(list.size) + } + } + gridAdapter.submitList(list) + requestFocus() } + } class SpanGridAdapter : MutableListAdapter>( From baeb541017055aaf5b72ebc032ac9726763c9c07 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 01:51:03 +0200 Subject: [PATCH 08/16] Allow disabling focus for Compose ViewHolders --- .../api/dpadrecyclerview-compose.api | 5 +++-- .../compose/DpadComposeFocusViewHolder.kt | 10 ++++------ .../sample/ui/screen/grid/GridSpanHeaderFragment.kt | 7 ++++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index 6adf4be7..7398182a 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -4,9 +4,10 @@ public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensions public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder { public static final field $stable I - public fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function3;)V - public synthetic fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;ZLkotlin/jvm/functions/Function3;)V + public synthetic fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;ZLkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getItem ()Ljava/lang/Object; + public final fun setFocusable (Z)V public final fun setItemState (Ljava/lang/Object;)V } diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt index 5ca546f6..3cc63ad3 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -19,7 +19,6 @@ package com.rubensousa.dpadrecyclerview.compose import android.view.ViewGroup import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.recyclerview.widget.RecyclerView @@ -46,17 +45,16 @@ import androidx.recyclerview.widget.RecyclerView class DpadComposeFocusViewHolder( parent: ViewGroup, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, + isFocusable: Boolean = true, private val content: @Composable (item: T) -> Unit -) : RecyclerView.ViewHolder(ComposeView(parent.context)) { +) : RecyclerView.ViewHolder(DpadComposeView(parent.context)) { private val itemState = mutableStateOf(null) - private val composeView = itemView as ComposeView + private val composeView = itemView as DpadComposeView init { composeView.apply { - isFocusable = true - isFocusableInTouchMode = true - descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + this@DpadComposeFocusViewHolder.setFocusable(isFocusable) setViewCompositionStrategy(compositionStrategy) setContent { itemState.value?.let { item -> diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt index 3669bc03..d47c96c5 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt @@ -103,7 +103,10 @@ class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { } else -> { - DpadComposeFocusViewHolder(parent) { _ -> + DpadComposeFocusViewHolder( + parent, + isFocusable = false + ) { _ -> Text( modifier = Modifier.padding( horizontal = 48.dp, @@ -113,8 +116,6 @@ class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { color = Color.White, style = MaterialTheme.typography.titleLarge, ) - }.also { - it.setFocusable(false) } } } From 2585736c6b0c94a798ab3a567a88ebb59e4c9f90 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 02:02:10 +0200 Subject: [PATCH 09/16] Revert DpadComposeView change --- .../dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt index 3cc63ad3..f16b46f6 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -19,6 +19,7 @@ package com.rubensousa.dpadrecyclerview.compose import android.view.ViewGroup import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.recyclerview.widget.RecyclerView @@ -47,10 +48,10 @@ class DpadComposeFocusViewHolder( compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, isFocusable: Boolean = true, private val content: @Composable (item: T) -> Unit -) : RecyclerView.ViewHolder(DpadComposeView(parent.context)) { +) : RecyclerView.ViewHolder(ComposeView(parent.context)) { private val itemState = mutableStateOf(null) - private val composeView = itemView as DpadComposeView + private val composeView = itemView as ComposeView init { composeView.apply { From 40583582224282d090752a5a1c9ad1a6d60fded6 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 02:04:05 +0200 Subject: [PATCH 10/16] Bump to 1.3.0-alpha04 --- docs/changelog.md | 14 ++++++++++++++ gradle.properties | 2 +- mkdocs.yml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1cb37180..f543c337 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,20 @@ ## Version 1.3.0 +### 1.3.0-alpha04 + +2024-06-04 + +#### New Features + +- Added `isFocusable` to `DpadComposeFocusViewHolder` to allow disabling focus for some items. + +#### Bug fixes + +- Fixed initial selection being always at position 0, even when that view is not focusable. +- Fixed some rows not receiving focus in grids using `DpadSpanSizeLookup`. ([#217](https://github.com/rubensousa/DpadRecyclerView/issues/217)) +- Fixed fast scrolling not working correctly in some grids using `DpadSpanSizeLookup`. ([#218](https://github.com/rubensousa/DpadRecyclerView/issues/218)) + ### 1.3.0-alpha03 2024-05-31 diff --git a/gradle.properties b/gradle.properties index c9556f88..fd84374e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,4 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.enableR8.fullMode=true -LIBRARY_VERSION=1.3.0-alpha03 \ No newline at end of file +LIBRARY_VERSION=1.3.0-alpha04 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 22c818a4..24a3adda 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,7 +24,7 @@ theme: extra: dpadrecyclerview: - version: '1.3.0-alpha03' + version: '1.3.0-alpha04' social: - icon: 'fontawesome/brands/github' link: 'https://github.com/rubensousa/DpadRecyclerView' From 062ce38702fed9499a6ea4b2d154f45c9668c501 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 02:08:36 +0200 Subject: [PATCH 11/16] Update changelog --- docs/changelog.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index f543c337..55e4bc33 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,7 +8,8 @@ #### New Features -- Added `isFocusable` to `DpadComposeFocusViewHolder` to allow disabling focus for some items. +- Added `DpadSelectionSnapHelper` to improve selection on touch events. ([#215](https://github.com/rubensousa/DpadRecyclerView/pull/215/files)) +- Added `isFocusable` to `DpadComposeFocusViewHolder` to allow disabling focus for some items. #### Bug fixes From 4aca7dc594980aebeb49c157be7879733bc43fb6 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 02:15:43 +0200 Subject: [PATCH 12/16] Decrease number of headers --- .../sample/ui/screen/grid/GridSpanHeaderFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt index d47c96c5..4d649892 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt @@ -68,7 +68,7 @@ class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { }) val list = mutableListOf() - val headers = 50 + val headers = 25 repeat(headers) { list.add(-1) repeat(getSpanCount()) { From a9066ab92110381efbe4db0cea5af82cd802993c Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 03:05:14 +0200 Subject: [PATCH 13/16] Do not go past the focused position when searching for the next span --- .../layoutmanager/focus/SpanFocusFinder.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt index 34bddbf0..265d3d45 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/focus/SpanFocusFinder.kt @@ -107,7 +107,9 @@ internal class SpanFocusFinder(private val configuration: LayoutConfiguration) { } // Now search until we find the cached span index or we go outside the edge - while (!isPositionOutOfBounds(currentPosition, edgePosition, forward)) { + while (!isPositionOutOfBounds(currentPosition, edgePosition, forward) + && currentPosition != focusedPosition + ) { if (isPositionAtCachedSpan(currentPosition, spanSizeLookup, reverseLayout)) { return currentPosition } @@ -183,7 +185,7 @@ internal class SpanFocusFinder(private val configuration: LayoutConfiguration) { ) && fitsInCurrentSpanGroup( lookup = lookup, - currentSpanEnd = currentSpan + spanDir, + spanIndex = currentSpan + spanDir, position = currentPos + posDir, spanDir = spanDir ) @@ -224,14 +226,14 @@ internal class SpanFocusFinder(private val configuration: LayoutConfiguration) { private fun fitsInCurrentSpanGroup( lookup: DpadSpanSizeLookup, - currentSpanEnd: Int, + spanIndex: Int, position: Int, spanDir: Int, ): Boolean { val nextSpan = getSpanEnd( lookup = lookup, position = position, - spanIndex = currentSpanEnd, + spanIndex = spanIndex, spanDir = spanDir ) return nextSpan >= 0 && nextSpan <= spanCount - 1 From 1b4a96c4923aa93dc3a1d11c7788f3a3bd51d832 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 03:13:58 +0200 Subject: [PATCH 14/16] Add gap to headers --- .../sample/ui/screen/grid/GridSpanHeaderFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt index 4d649892..03634817 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/grid/GridSpanHeaderFragment.kt @@ -71,7 +71,7 @@ class GridSpanHeaderFragment : Fragment(R.layout.screen_recyclerview) { val headers = 25 repeat(headers) { list.add(-1) - repeat(getSpanCount()) { + repeat(getSpanCount() * 2 - 1) { list.add(list.size) } } From 0705ce08f64bd42245d57ec84f3a1df98e728aa9 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 03:15:46 +0200 Subject: [PATCH 15/16] Find first focusable view starting from adapter order --- .../layoutmanager/layout/PivotLayout.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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 50a9eb3e..a8b9ee1c 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 @@ -215,16 +215,10 @@ internal class PivotLayout( } private fun findFirstFocusableView(): View? { - val childCount = layoutInfo.getChildCount() - for (i in 0 until childCount) { - val actualIndex = if (layoutInfo.shouldReverseLayout()) { - childCount - 1 - i - } else { - i - } - val child = layoutInfo.getChildAt(actualIndex) - if (child != null && child.hasFocusable()) { - return child + for (i in 0 until layoutManager.itemCount) { + val view = layoutInfo.findViewByAdapterPosition(i) + if (view != null && layoutInfo.isViewFocusable(view)) { + return view } } return null From 61fddb8ca5e191d86eb29e7dff2b5815baabedab Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 19:19:05 +0200 Subject: [PATCH 16/16] Add UI tests for grid spans with headers not focusable --- .../test/RecyclerViewFragment.kt | 32 ++ .../helpers/RecyclerViewTestExtensions.kt | 4 + .../test/helpers/TestAdapter.kt | 134 ++++++++ .../scrolling/GridSpanHeaderScrollTest.kt | 307 ++++++++++++++++++ .../layout/dpadrecyclerview_grid_header.xml | 22 ++ .../res/layout/dpadrecyclerview_item_grid.xml | 22 ++ .../src/androidTest/res/values/dimens.xml | 19 ++ .../ui/widgets/common/MutableListAdapter.kt | 5 +- 8 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt create mode 100644 dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml create mode 100644 dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml create mode 100644 dpadrecyclerview/src/androidTest/res/values/dimens.xml diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt new file mode 100644 index 00000000..c019258b --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt @@ -0,0 +1,32 @@ +/* + * 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.test + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.rubensousa.dpadrecyclerview.DpadRecyclerView +import com.rubensousa.dpadrecyclerview.testing.R + +class RecyclerViewFragment : Fragment(R.layout.dpadrecyclerview_test_container) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.recyclerView).requestFocus() + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt index 53ebee4b..2cb065c7 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt @@ -207,6 +207,10 @@ fun waitForCondition( .perform(DpadViewActions.waitForCondition(description, condition)) } +fun waitForLayout() { + waitForCondition("Waiting for layout") { recyclerView -> !recyclerView.isLayoutRequested } +} + fun onRecyclerView( description: String, id: Int = R.id.recyclerView, diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt new file mode 100644 index 00000000..0e38c17b --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt @@ -0,0 +1,134 @@ +/* + * 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.test.helpers + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import java.util.Collections +import java.util.concurrent.Executors + +abstract class TestAdapter: RecyclerView.Adapter() { + + companion object { + private val BACKGROUND_EXECUTOR = Executors.newFixedThreadPool(4) + private val MAIN_THREAD_HANDLER = Handler(Looper.getMainLooper()) + } + + private var items: MutableList = Collections.emptyList() + private var currentVersion = 0 + + @SuppressLint("NotifyDataSetChanged") + fun replaceList(items: MutableList) { + this.items = items + currentVersion++ + notifyDataSetChanged() + } + + fun submitList(newList: MutableList, commitCallback: Runnable? = null) { + val version = ++currentVersion + if (items === newList) { + commitCallback?.run() + return + } + + if (newList.isEmpty()) { + val removed = items.size + items = Collections.emptyList() + notifyItemRangeRemoved(0, removed) + return + } + + if (items.isEmpty()) { + items = newList + notifyItemRangeInserted(0, newList.size) + return + } + + val oldList = items + BACKGROUND_EXECUTOR.execute { + val diffResult = calculateDiff(oldList, newList) + MAIN_THREAD_HANDLER.post { + if (version == currentVersion) { + latchList(newList, diffResult, commitCallback) + } + } + } + } + + private fun calculateDiff( + oldList: List, + newList: List + ): DiffUtil.DiffResult { + return DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem == newItem + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem == newItem + } + }) + } + + private fun latchList( + newList: MutableList, + result: DiffUtil.DiffResult, + commitCallback: Runnable? + ) { + items = newList + result.dispatchUpdatesTo(this) + commitCallback?.run() + } + + fun removeAt(index: Int) { + if (index >= 0 && index < items.size) { + currentVersion++ + items.removeAt(index) + notifyItemRemoved(index) + } + + } + + fun move(from: Int, to: Int) { + currentVersion++ + Collections.swap(items, from, to) + notifyItemMoved(from, to) + } + + fun addAt(index: Int, item: Int) { + currentVersion++ + items.add(index, item) + notifyItemInserted(index) + } + + override fun getItemCount(): Int = items.size + + fun getItem(position: Int) = items[position] +} \ No newline at end of file diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt new file mode 100644 index 00000000..4bebe26a --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt @@ -0,0 +1,307 @@ +/* + * 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.test.tests.scrolling + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadSpanSizeLookup +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.R +import com.rubensousa.dpadrecyclerview.test.RecyclerViewFragment +import com.rubensousa.dpadrecyclerview.test.helpers.TestAdapter +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusPosition +import com.rubensousa.dpadrecyclerview.test.helpers.assertSelectedPosition +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState +import com.rubensousa.dpadrecyclerview.test.helpers.waitForLayout +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 +import kotlin.math.abs +import com.rubensousa.dpadrecyclerview.testing.R as RTesting + +private val headerViewType = 0 +private val normalViewType = 1 + +class GridSpanHeaderScrollTest { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + private lateinit var fragmentScenario: FragmentScenario + + private val defaultGrid = listOf( + -1, + 1, 2, 3, 4, + -2, + 3, 4, 5, 6, + -3, + 7, 8, 9, 10 + ) + + @Before + fun setup() { + launchFragment() + } + + @Test + fun testFocusIsAtFirstFocusablePosition() { + // given + setContent(items = defaultGrid, spanCount = 4) + + // when + waitForLayout() + + // then + assertFocusPosition(position = 1) + assertSelectedPosition(position = 1) + } + + @Test + fun testFocusUpWhenAboveItemDoesNotExistGoesToStartOfSpan() { + // given + setContent( + items = listOf( + -1, + 1, 2, 3, + -2, + 4, 5, 6, 7 + ), spanCount = 4 + ) + KeyEvents.pressDown(times = 1) + KeyEvents.pressRight(times = 3) + waitForIdleScrollState() + + assertFocusAndSelection(8) + + // when + KeyEvents.pressUp() + + // then + assertFocusAndSelection(position = 1) + } + + @Test + fun testLastRowReceivesFocus() { + // given + val headers = 3 + val spans = 5 + val items = buildList { + repeat(headers) { header -> + add(-(header + 1)) + repeat(spans) { + add(size) + } + } + } + setContent(items, spans) + + // when + KeyEvents.pressDown(times = headers, delay = 500) + waitForIdleScrollState() + + // then + assertFocusPosition(position = items.size - spans) + } + + @Test + fun testFastScrollWithIncompleteRows() { + // given + val headers = 10 + val spans = 5 + val items = buildList { + repeat(headers) { header -> + add(-(header + 1)) + repeat(spans - 1) { + add(size) + } + } + } + setContent(items, spans) + + // when + KeyEvents.pressDown(times = headers) + waitForIdleScrollState() + assertFocusAndSelection(items.size - spans + 1) + KeyEvents.pressUp(times = headers) + waitForIdleScrollState( + + ) + + // then + assertFocusAndSelection(1) + } + + @Test + fun testConsecutiveHeadersLeadToFocusSearch() { + // given + val headers = 10 + val spanCount = 4 + val items = buildList { + add(-1) + addAll(listOf(1, 2, 3, 4)) + repeat(headers - 1) { + add(-size) + } + addAll(listOf(5, 6, 7, 8)) + } + setContent( + items = items, + spanCount = spanCount + ) + + // when + KeyEvents.pressDown(times = headers) + waitForIdleScrollState() + assertFocusAndSelection(items.size - spanCount) + + // then + KeyEvents.pressUp(times = headers) + waitForIdleScrollState() + assertFocusAndSelection(1) + } + + @Test + fun testFocusStaysInTheSameSpan() { + // given + val spanCount = 4 + setContent(defaultGrid, spanCount) + val startPosition = 1 + + repeat(spanCount) { currentSpan -> + assertFocusPosition(position = startPosition + currentSpan) + KeyEvents.pressDown() + waitForIdleScrollState() + assertFocusAndSelection(position = startPosition + currentSpan + spanCount + 1) + KeyEvents.pressRight() + KeyEvents.pressUp() + waitForIdleScrollState() + } + + } + + @Test + fun testSearchFocusOutOfBoundsDoesNotCrash() { + // given + val spanCount = 4 + setContent(defaultGrid, spanCount) + + // when + KeyEvents.pressDown(times = defaultGrid.size) + waitForIdleScrollState() + + // then + assertFocusAndSelection(defaultGrid.size - spanCount) + } + + private fun setContent(items: List, spanCount: Int) { + onRecyclerView("Set content") { recyclerView -> + recyclerView.setSpanCount(spanCount) + recyclerView.addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = recyclerView.resources.getDimensionPixelSize( + R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + val gridAdapter = Adapter() + gridAdapter.submitList(items.toMutableList()) + recyclerView.setSpanSizeLookup(object : DpadSpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + val itemViewType = gridAdapter.getItemViewType(position) + return when (itemViewType) { + normalViewType -> 1 + else -> spanCount + } + } + }) + recyclerView.adapter = gridAdapter + } + } + + private class Adapter : TestAdapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpanViewHolder { + return when (viewType) { + normalViewType -> GridViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.dpadrecyclerview_item_grid, parent, false) + ) + + else -> HeaderViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.dpadrecyclerview_grid_header, parent, false) + ) + } + } + + override fun onBindViewHolder(holder: SpanViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item > 0) { + normalViewType + } else { + headerViewType + } + } + + } + + private abstract class SpanViewHolder( + view: View + ) : RecyclerView.ViewHolder(view) { + abstract fun bind(item: Int) + } + + private class GridViewHolder(view: View) : SpanViewHolder(view) { + private val textView = view.findViewById(R.id.textView) + override fun bind(item: Int) { + textView.text = item.toString() + } + } + + private class HeaderViewHolder(view: View) : SpanViewHolder(view) { + private val textView = view.findViewById(R.id.textView) + override fun bind(item: Int) { + textView.text = "Header ${abs(item)}" + } + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = RTesting.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + +} diff --git a/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml new file mode 100644 index 00000000..d9f1956e --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml new file mode 100644 index 00000000..0e83d3de --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/dpadrecyclerview/src/androidTest/res/values/dimens.xml b/dpadrecyclerview/src/androidTest/res/values/dimens.xml new file mode 100644 index 00000000..6e1abf27 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/values/dimens.xml @@ -0,0 +1,19 @@ + + + + 16dp + \ No newline at end of file diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt index 38ce2417..7ffa8f49 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt @@ -26,14 +26,15 @@ import java.util.Collections import java.util.concurrent.Executors abstract class MutableListAdapter( - private val itemCallback: ItemCallback) : RecyclerView.Adapter() { + private val itemCallback: ItemCallback +) : RecyclerView.Adapter() { companion object { private val BACKGROUND_EXECUTOR = Executors.newFixedThreadPool(4) private val MAIN_THREAD_HANDLER = Handler(Looper.getMainLooper()) } - private var items : MutableList = Collections.emptyList() + private var items: MutableList = Collections.emptyList() private var currentVersion = 0 @SuppressLint("NotifyDataSetChanged")