From 1eaa0c016512e2cd09a80f5e5915c927bf388c6c Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 25 Nov 2023 03:04:30 +0100 Subject: [PATCH] Add support for scrollbars --- .../dpadrecyclerview/DpadScroller.kt | 15 ++ .../layoutmanager/PivotLayoutManager.kt | 74 +++++++++- .../layoutmanager/layout/LayoutInfo.kt | 16 ++ .../scroll/DpadScrollbarHelper.kt | 137 ++++++++++++++++++ .../ui/screen/text/TextScrollingFragment.kt | 2 +- .../src/main/res/drawable/scrollbar_thumb.xml | 8 + .../src/main/res/drawable/scrollbar_track.xml | 7 + .../main/res/layout/screen_text_scrolling.xml | 6 + 8 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/scroll/DpadScrollbarHelper.kt create mode 100644 sample/src/main/res/drawable/scrollbar_thumb.xml create mode 100644 sample/src/main/res/drawable/scrollbar_track.xml diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScroller.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScroller.kt index d9542f6c..9fdf4883 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScroller.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScroller.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2023 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 import android.view.KeyEvent 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 e94694ba..b05e7217 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt @@ -23,7 +23,6 @@ import android.os.Parcelable import android.util.AttributeSet import android.view.View import android.view.ViewGroup -import androidx.annotation.VisibleForTesting import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.ChildAlignment @@ -41,6 +40,7 @@ import com.rubensousa.dpadrecyclerview.layoutmanager.focus.SpanFocusFinder import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutInfo import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutPrefetchCollector import com.rubensousa.dpadrecyclerview.layoutmanager.layout.PivotLayout +import com.rubensousa.dpadrecyclerview.layoutmanager.scroll.DpadScrollbarHelper import com.rubensousa.dpadrecyclerview.layoutmanager.scroll.LayoutScroller /** @@ -169,6 +169,73 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager() state: RecyclerView.State ): Int = pivotLayout.scrollVerticallyBy(dy, recycler, state) + override fun computeHorizontalScrollOffset(state: RecyclerView.State): Int { + return computeScrollOffset(state) + } + + override fun computeVerticalScrollOffset(state: RecyclerView.State): Int { + return computeScrollOffset(state) + } + + override fun computeHorizontalScrollExtent(state: RecyclerView.State): Int { + return computeScrollExtent(state) + } + + override fun computeVerticalScrollExtent(state: RecyclerView.State): Int { + return computeScrollExtent(state) + } + + override fun computeHorizontalScrollRange(state: RecyclerView.State): Int { + return computeScrollRange(state) + } + + override fun computeVerticalScrollRange(state: RecyclerView.State): Int { + return computeScrollRange(state) + } + + private fun computeScrollOffset(state: RecyclerView.State): Int { + if (childCount == 0) { + return 0 + } + return DpadScrollbarHelper.computeScrollOffset( + state = state, + orientationHelper = layoutInfo.orientationHelper, + startChild = layoutInfo.findFirstVisibleChild(), + endChild = layoutInfo.findLastVisibleChild(), + lm = this, + smoothScrollbarEnabled = true, + reverseLayout = configuration.reverseLayout + ) + } + + private fun computeScrollExtent(state: RecyclerView.State): Int { + if (childCount == 0) { + return 0 + } + return DpadScrollbarHelper.computeScrollExtent( + state = state, + orientationHelper = layoutInfo.orientationHelper, + startChild = layoutInfo.findFirstVisibleChild(), + endChild = layoutInfo.findLastVisibleChild(), + lm = this, + smoothScrollbarEnabled = true, + ) + } + + private fun computeScrollRange(state: RecyclerView.State): Int { + if (childCount == 0) { + return 0 + } + return DpadScrollbarHelper.computeScrollRange( + state = state, + orientationHelper = layoutInfo.orientationHelper, + startChild = layoutInfo.findFirstVisibleChild(), + endChild = layoutInfo.findLastVisibleChild(), + lm = this, + smoothScrollbarEnabled = true, + ) + } + override fun scrollToPosition(position: Int) { scroller.scrollToPosition(position) } @@ -571,9 +638,4 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager() } } - @VisibleForTesting - internal fun getFocusFinderSpanCount(): Int { - return spanFocusFinder.spanCount - } - } 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 f994028a..e4aa5c08 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 @@ -281,6 +281,22 @@ internal class LayoutInfo( ) } + fun findFirstVisibleChild(): View? { + val childPosition = findFirstVisiblePosition() + if (childPosition == RecyclerView.NO_POSITION) { + return null + } + return layout.findViewByPosition(childPosition) + } + + fun findLastVisibleChild(): View? { + val childPosition = findLastVisiblePosition() + if (childPosition == RecyclerView.NO_POSITION) { + return null + } + return layout.findViewByPosition(childPosition) + } + /** * @param startIndex index at which the search should start * @param endIndex index at which the search should stop (not inclusive) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/scroll/DpadScrollbarHelper.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/scroll/DpadScrollbarHelper.kt new file mode 100644 index 00000000..b8a1d17a --- /dev/null +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/scroll/DpadScrollbarHelper.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * 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. + */ + +/* + * Copyright 2023 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.layoutmanager.scroll + +import android.view.View +import androidx.recyclerview.widget.OrientationHelper +import androidx.recyclerview.widget.RecyclerView + +/** + * A helper class to do scroll offset calculations. + * Adapted from ScrollbarHelper of androidx.recyclerview + */ +internal object DpadScrollbarHelper { + + /** + * @param startChild View closest to start of the list. (top or left) + * @param endChild View closest to end of the list (bottom or right) + */ + fun computeScrollOffset( + state: RecyclerView.State, + orientationHelper: OrientationHelper, + startChild: View?, + endChild: View?, + lm: RecyclerView.LayoutManager, + smoothScrollbarEnabled: Boolean, + reverseLayout: Boolean + ): Int { + if (lm.childCount == 0 || state.itemCount == 0 || startChild == null || endChild == null) { + return 0 + } + val minPosition = Math.min(lm.getPosition(startChild), lm.getPosition(endChild)) + val maxPosition = Math.max(lm.getPosition(startChild), lm.getPosition(endChild)) + val itemsBefore = if (reverseLayout) { + Math.max(0, state.itemCount - maxPosition - 1) + } else { + Math.max(0, minPosition) + } + if (!smoothScrollbarEnabled) { + return itemsBefore + } + val laidOutArea = Math.abs( + orientationHelper.getDecoratedEnd(endChild) + - orientationHelper.getDecoratedStart(startChild) + ) + val itemRange = Math.abs((lm.getPosition(startChild) - lm.getPosition(endChild))) + 1 + val avgSizePerRow = laidOutArea.toFloat() / itemRange + return Math.round( + itemsBefore * avgSizePerRow + ((orientationHelper.startAfterPadding + - orientationHelper.getDecoratedStart(startChild))) + ) + } + + /** + * @param startChild View closest to start of the list. (top or left) + * @param endChild View closest to end of the list (bottom or right) + */ + fun computeScrollExtent( + state: RecyclerView.State, + orientationHelper: OrientationHelper, + startChild: View?, + endChild: View?, + lm: RecyclerView.LayoutManager, + smoothScrollbarEnabled: Boolean + ): Int { + if ((lm.childCount == 0) || (state.itemCount == 0) || (startChild == null + ) || (endChild == null) + ) { + return 0 + } + if (!smoothScrollbarEnabled) { + return Math.abs(lm.getPosition(startChild) - lm.getPosition(endChild)) + 1 + } + val extend = (orientationHelper.getDecoratedEnd(endChild) + - orientationHelper.getDecoratedStart(startChild)) + return Math.min(orientationHelper.totalSpace, extend) + } + + /** + * @param startChild View closest to start of the list. (top or left) + * @param endChild View closest to end of the list (bottom or right) + */ + fun computeScrollRange( + state: RecyclerView.State, + orientationHelper: OrientationHelper, + startChild: View?, + endChild: View?, + lm: RecyclerView.LayoutManager, + smoothScrollbarEnabled: Boolean + ): Int { + if ((lm.childCount == 0) + || (state.itemCount == 0) + || (startChild == null) + || (endChild == null) + ) { + return 0 + } + if (!smoothScrollbarEnabled) { + return state.itemCount + } + // smooth scrollbar enabled. try to estimate better. + val laidOutArea = (orientationHelper.getDecoratedEnd(endChild) + - orientationHelper.getDecoratedStart(startChild)) + val laidOutRange = (Math.abs(lm.getPosition(startChild) - lm.getPosition(endChild)) + 1) + // estimate a size for full list. + return (laidOutArea.toFloat() / laidOutRange * state.itemCount).toInt() + } + +} diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/text/TextScrollingFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/text/TextScrollingFragment.kt index dc8be8d7..be8f8744 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/text/TextScrollingFragment.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/text/TextScrollingFragment.kt @@ -29,7 +29,7 @@ class TextScrollingFragment : Fragment(R.layout.screen_text_scrolling) { val adapter = TextAdapter() val text = view.resources.getString(R.string.placeholder_text) val list = mutableListOf() - repeat(50) { + repeat(20) { list.add(text) } adapter.submitList(list) diff --git a/sample/src/main/res/drawable/scrollbar_thumb.xml b/sample/src/main/res/drawable/scrollbar_thumb.xml new file mode 100644 index 00000000..0e7f31b4 --- /dev/null +++ b/sample/src/main/res/drawable/scrollbar_thumb.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/scrollbar_track.xml b/sample/src/main/res/drawable/scrollbar_track.xml new file mode 100644 index 00000000..2080755e --- /dev/null +++ b/sample/src/main/res/drawable/scrollbar_track.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/sample/src/main/res/layout/screen_text_scrolling.xml b/sample/src/main/res/layout/screen_text_scrolling.xml index 45ff661a..21654508 100644 --- a/sample/src/main/res/layout/screen_text_scrolling.xml +++ b/sample/src/main/res/layout/screen_text_scrolling.xml @@ -48,6 +48,12 @@ android:layout_height="match_parent" android:layout_marginStart="24dp" android:background="@color/list_text_background" + android:fadeScrollbars="false" + android:scrollbarAlwaysDrawVerticalTrack="true" + android:scrollbarSize="6dp" + android:scrollbarThumbVertical="@drawable/scrollbar_thumb" + android:scrollbarTrackVertical="@drawable/scrollbar_track" + android:scrollbars="vertical" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/guideline" app:layout_constraintStart_toEndOf="@id/backButton"