From addf2a906c86e74d75cb7dae646e3a153062fc7a Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sat, 11 Jul 2026 13:30:49 +0500 Subject: [PATCH 01/14] Stabilize browser scrollbar thumb size --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 59f093ac98c3..6074aa7acd2e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -106,10 +106,49 @@ class RecyclerFastScroller private var hideOverride = false private var adapter: RecyclerView.Adapter<*>? = null + + private var cachedHandleHeight = 0 + private var cachedHandleHeightItemCount = RecyclerView.NO_POSITION + private var cachedHandleHeightBarHeight = 0 + + private fun resetCachedHandleHeight() { + cachedHandleHeight = 0 + cachedHandleHeightItemCount = RecyclerView.NO_POSITION + cachedHandleHeightBarHeight = 0 + } + private val adapterObserver: RecyclerView.AdapterDataObserver = object : RecyclerView.AdapterDataObserver() { override fun onChanged() { super.onChanged() + resetCachedHandleHeight() + requestLayout() + } + + override fun onItemRangeChanged( + positionStart: Int, + itemCount: Int, + ) { + super.onItemRangeChanged(positionStart, itemCount) + resetCachedHandleHeight() + requestLayout() + } + + override fun onItemRangeInserted( + positionStart: Int, + itemCount: Int, + ) { + super.onItemRangeInserted(positionStart, itemCount) + resetCachedHandleHeight() + requestLayout() + } + + override fun onItemRangeRemoved( + positionStart: Int, + itemCount: Int, + ) { + super.onItemRangeRemoved(positionStart, itemCount) + resetCachedHandleHeight() requestLayout() } } @@ -253,6 +292,7 @@ class RecyclerFastScroller this.adapter?.unregisterAdapterDataObserver(adapterObserver) adapter?.registerAdapterDataObserver(adapterObserver) this.adapter = adapter + resetCachedHandleHeight() } /** @@ -405,16 +445,7 @@ class RecyclerFastScroller val barHeight = bar.height val ratio = scrollOffset.toFloat() / (verticalScrollRange - barHeight) - var calculatedHandleHeight = (barHeight.toFloat() / verticalScrollRange * barHeight).toInt() - if (calculatedHandleHeight < minScrollHandleHeight) { - calculatedHandleHeight = minScrollHandleHeight - } - - if (calculatedHandleHeight >= barHeight) { - translationX = hiddenTranslationX.toFloat() - hideOverride = true - return - } + val calculatedHandleHeight = getCachedHandleHeight(barHeight, verticalScrollRange) hideOverride = false @@ -423,6 +454,27 @@ class RecyclerFastScroller handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight) } + private fun getCachedHandleHeight( + barHeight: Int, + verticalScrollRange: Int, + ): Int { + val itemCount = adapter?.itemCount ?: RecyclerView.NO_POSITION + if (itemCount != cachedHandleHeightItemCount || barHeight != cachedHandleHeightBarHeight) { + resetCachedHandleHeight() + cachedHandleHeightItemCount = itemCount + cachedHandleHeightBarHeight = barHeight + } + + if (cachedHandleHeight == 0) { + cachedHandleHeight = + (barHeight.toFloat() / verticalScrollRange * barHeight) + .toInt() + .coerceAtLeast(minScrollHandleHeight) + } + + return cachedHandleHeight + } + companion object { private val DEFAULT_AUTO_HIDE_DELAY = 1500.milliseconds } From 0bd67a1cee09d7b16a121173f2a15f502968f2fe Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sat, 11 Jul 2026 13:56:28 +0500 Subject: [PATCH 02/14] Try stabilize scrollbar thumb position --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 6074aa7acd2e..6889c7e3fc12 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -436,17 +436,15 @@ class RecyclerFastScroller super.onLayout(changed, left, top, right, bottom) if (recyclerView == null) return - val scrollOffset = recyclerView!!.computeVerticalScrollOffset() + appBarLayoutOffset val verticalScrollRange = ( recyclerView!!.computeVerticalScrollRange() + recyclerView!!.paddingBottom ) val barHeight = bar.height - val ratio = scrollOffset.toFloat() / (verticalScrollRange - barHeight) + val ratio = getScrollProportion() val calculatedHandleHeight = getCachedHandleHeight(barHeight, verticalScrollRange) - hideOverride = false val y = ratio * (barHeight - calculatedHandleHeight) @@ -475,6 +473,25 @@ class RecyclerFastScroller return cachedHandleHeight } + private fun getScrollProportion(): Float { + val recyclerView = recyclerView ?: return 0f + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return 0f + val itemCount = adapter?.itemCount ?: return 0f + + if (itemCount <= 1) return 0f + if (!recyclerView.canScrollVertically(-1)) return 0f + if (!recyclerView.canScrollVertically(1)) return 1f + + val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() + if (firstVisiblePosition == RecyclerView.NO_POSITION) return 0f + + val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition) ?: return 0f + val hiddenHeight = (recyclerView.paddingTop - firstVisibleView.top).coerceAtLeast(0) + val firstItemFraction = hiddenHeight.toFloat() / firstVisibleView.height.coerceAtLeast(1) + + return ((firstVisiblePosition + firstItemFraction) / (itemCount - 1)).coerceIn(0f, 1f) + } + companion object { private val DEFAULT_AUTO_HIDE_DELAY = 1500.milliseconds } From a93b8ed2e584f8b734ed34a94c36f6cea0a93a95 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sun, 12 Jul 2026 22:14:35 +0500 Subject: [PATCH 03/14] refactor --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 123 +++++++++++++----- 1 file changed, 88 insertions(+), 35 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 6889c7e3fc12..f032b139fefd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -110,6 +110,15 @@ class RecyclerFastScroller private var cachedHandleHeight = 0 private var cachedHandleHeightItemCount = RecyclerView.NO_POSITION private var cachedHandleHeightBarHeight = 0 + private var cachedVisibleItemCount = 0 + private var cachedVisibleItemCountItemCount = RecyclerView.NO_POSITION + private var cachedVisibleItemCountBarHeight = 0 + + private fun resetCachedVisibleItemCount() { + cachedVisibleItemCount = 0 + cachedVisibleItemCountItemCount = RecyclerView.NO_POSITION + cachedVisibleItemCountBarHeight = 0 + } private fun resetCachedHandleHeight() { cachedHandleHeight = 0 @@ -117,11 +126,16 @@ class RecyclerFastScroller cachedHandleHeightBarHeight = 0 } + private fun resetCachedScrollMetrics() { + resetCachedHandleHeight() + resetCachedVisibleItemCount() + } + private val adapterObserver: RecyclerView.AdapterDataObserver = object : RecyclerView.AdapterDataObserver() { override fun onChanged() { super.onChanged() - resetCachedHandleHeight() + resetCachedScrollMetrics() requestLayout() } @@ -130,7 +144,7 @@ class RecyclerFastScroller itemCount: Int, ) { super.onItemRangeChanged(positionStart, itemCount) - resetCachedHandleHeight() + resetCachedScrollMetrics() requestLayout() } @@ -139,7 +153,7 @@ class RecyclerFastScroller itemCount: Int, ) { super.onItemRangeInserted(positionStart, itemCount) - resetCachedHandleHeight() + resetCachedScrollMetrics() requestLayout() } @@ -148,7 +162,7 @@ class RecyclerFastScroller itemCount: Int, ) { super.onItemRangeRemoved(positionStart, itemCount) - resetCachedHandleHeight() + resetCachedScrollMetrics() requestLayout() } } @@ -292,7 +306,7 @@ class RecyclerFastScroller this.adapter?.unregisterAdapterDataObserver(adapterObserver) adapter?.registerAdapterDataObserver(adapterObserver) this.adapter = adapter - resetCachedHandleHeight() + resetCachedScrollMetrics() } /** @@ -434,29 +448,86 @@ class RecyclerFastScroller bottom: Int, ) { super.onLayout(changed, left, top, right, bottom) - if (recyclerView == null) return - val verticalScrollRange = ( - recyclerView!!.computeVerticalScrollRange() + - recyclerView!!.paddingBottom - ) + val recyclerView = recyclerView ?: return + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return + val itemCount = recyclerView.adapter?.itemCount ?: return + if (itemCount == 0) return + + val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() + val lastVisiblePosition = layoutManager.findLastVisibleItemPosition() + if (firstVisiblePosition == RecyclerView.NO_POSITION || lastVisiblePosition == RecyclerView.NO_POSITION) return val barHeight = bar.height - val ratio = getScrollProportion() + val visibleItemCount = + getCachedVisibleItemCount( + barHeight, + itemCount, + firstVisiblePosition, + lastVisiblePosition, + ) + val calculatedHandleHeight = getCachedHandleHeight(barHeight, itemCount, visibleItemCount) + + val ratio = + getScrollProportion( + recyclerView, + layoutManager, + itemCount, + visibleItemCount, + firstVisiblePosition, + ) - val calculatedHandleHeight = getCachedHandleHeight(barHeight, verticalScrollRange) hideOverride = false val y = ratio * (barHeight - calculatedHandleHeight) - handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight) } + private fun getScrollProportion( + recyclerView: RecyclerView, + layoutManager: LinearLayoutManager, + itemCount: Int, + visibleItemCount: Int, + firstVisiblePosition: Int, + ): Float { + if (!recyclerView.canScrollVertically(-1)) return 0f + if (!recyclerView.canScrollVertically(1)) return 1f + + val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition) ?: return 0f + val hiddenHeight = (recyclerView.paddingTop - firstVisibleView.top).coerceAtLeast(0) + val firstItemFraction = hiddenHeight.toFloat() / firstVisibleView.height.coerceAtLeast(1) + + val scrollableItems = (itemCount - visibleItemCount).coerceAtLeast(1) + return ((firstVisiblePosition + firstItemFraction) / scrollableItems).coerceIn(0f, 1f) + } + + private fun getCachedVisibleItemCount( + barHeight: Int, + itemCount: Int, + firstVisiblePosition: Int, + lastVisiblePosition: Int, + ): Int { + if (itemCount != cachedVisibleItemCountItemCount || barHeight != cachedVisibleItemCountBarHeight) { + resetCachedVisibleItemCount() + cachedVisibleItemCountItemCount = itemCount + cachedVisibleItemCountBarHeight = barHeight + } + + if (cachedVisibleItemCount == 0) { + cachedVisibleItemCount = + (lastVisiblePosition - firstVisiblePosition + 1) + .coerceAtLeast(1) + .coerceAtMost(itemCount) + } + + return cachedVisibleItemCount + } + private fun getCachedHandleHeight( barHeight: Int, - verticalScrollRange: Int, + itemCount: Int, + visibleItemCount: Int, ): Int { - val itemCount = adapter?.itemCount ?: RecyclerView.NO_POSITION if (itemCount != cachedHandleHeightItemCount || barHeight != cachedHandleHeightBarHeight) { resetCachedHandleHeight() cachedHandleHeightItemCount = itemCount @@ -465,33 +536,15 @@ class RecyclerFastScroller if (cachedHandleHeight == 0) { cachedHandleHeight = - (barHeight.toFloat() / verticalScrollRange * barHeight) + (barHeight.toFloat() * visibleItemCount / itemCount) .toInt() .coerceAtLeast(minScrollHandleHeight) + .coerceAtMost(barHeight) } return cachedHandleHeight } - private fun getScrollProportion(): Float { - val recyclerView = recyclerView ?: return 0f - val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return 0f - val itemCount = adapter?.itemCount ?: return 0f - - if (itemCount <= 1) return 0f - if (!recyclerView.canScrollVertically(-1)) return 0f - if (!recyclerView.canScrollVertically(1)) return 1f - - val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() - if (firstVisiblePosition == RecyclerView.NO_POSITION) return 0f - - val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition) ?: return 0f - val hiddenHeight = (recyclerView.paddingTop - firstVisibleView.top).coerceAtLeast(0) - val firstItemFraction = hiddenHeight.toFloat() / firstVisibleView.height.coerceAtLeast(1) - - return ((firstVisiblePosition + firstItemFraction) / (itemCount - 1)).coerceIn(0f, 1f) - } - companion object { private val DEFAULT_AUTO_HIDE_DELAY = 1500.milliseconds } From 981be0ead2e567822b1077a2f11a649c57aad5aa Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sun, 12 Jul 2026 22:33:17 +0500 Subject: [PATCH 04/14] add comment --- .../src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index f032b139fefd..69bebaf0dee2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -126,6 +126,11 @@ class RecyclerFastScroller cachedHandleHeightBarHeight = 0 } + /** + * Cached scroll metrics are tied to the current adapter contents and viewport size. + * They are reset when adapter data changes so the thumb can be recalculated for the + * new list, but stay stable during normal scrolling. + */ private fun resetCachedScrollMetrics() { resetCachedHandleHeight() resetCachedVisibleItemCount() From f508abc96480c187362a7e3bf8c89ae347771c11 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sun, 12 Jul 2026 22:38:57 +0500 Subject: [PATCH 05/14] add comment to getCachedVisibleItemCount and getCachedHandleHeight funcs --- .../main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 69bebaf0dee2..25af21b03764 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -506,6 +506,11 @@ class RecyclerFastScroller return ((firstVisiblePosition + firstItemFraction) / scrollableItems).coerceIn(0f, 1f) } + /** + * Keeps the visible item count stable during scrolling. Recomputing it for every + * layout pass could change the thumb size as rows with different heights enter or + * leave the viewport. + */ private fun getCachedVisibleItemCount( barHeight: Int, itemCount: Int, @@ -528,6 +533,10 @@ class RecyclerFastScroller return cachedVisibleItemCount } + /** + * Calculates thumb height from the visible item count and total item count so the + * size remains stable while scrolling through rows with different heights. + */ private fun getCachedHandleHeight( barHeight: Int, itemCount: Int, From 99e2579b9be88aa5a526b56845da33e4c96b80a9 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sun, 12 Jul 2026 22:41:07 +0500 Subject: [PATCH 06/14] add comment to getScrollProportion --- .../src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 25af21b03764..0de1feeaceb3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -488,6 +488,11 @@ class RecyclerFastScroller handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight) } + /** + * Calculates thumb position from adapter positions so the scroll progress remains + * stable when rows have different heights. The fractional offset within the first + * visible row keeps the thumb moving smoothly between adapter positions. + */ private fun getScrollProportion( recyclerView: RecyclerView, layoutManager: LinearLayoutManager, From 82eba9ef482cf58b752d501437dcf965e0f9cc53 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Sun, 12 Jul 2026 23:10:34 +0500 Subject: [PATCH 07/14] Switch tags to RecyclerFastScroller --- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 2 ++ AnkiDroid/src/main/res/layout/dialog_tags.xml | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 92d3f5f628f7..92765120d74d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -41,6 +41,7 @@ import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.libanki.withCollapsedWhitespace import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.ui.attachFastScroller import com.ichi2.ui.AccessibleSearchView import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown import com.ichi2.utils.TagsUtil @@ -239,6 +240,7 @@ class TagsDialog : AnalyticsDialogFragment { tagsArrayAdapter = TagsArrayAdapter(tags) { binding.root.showMaxTagSelectedNotice(tags) } binding.tagsList.adapter = tagsArrayAdapter + binding.tagsList.attachFastScroller(R.id.tags_scroller) if (tags.isEmpty) { binding.noTagsTextView.visibility = View.VISIBLE } diff --git a/AnkiDroid/src/main/res/layout/dialog_tags.xml b/AnkiDroid/src/main/res/layout/dialog_tags.xml index 7625d3248b43..f66de7180aed 100644 --- a/AnkiDroid/src/main/res/layout/dialog_tags.xml +++ b/AnkiDroid/src/main/res/layout/dialog_tags.xml @@ -64,13 +64,25 @@ - + android:layout_below="@id/toolbar"> + + + + + Date: Mon, 13 Jul 2026 18:07:37 +0500 Subject: [PATCH 08/14] apply criticalay patch + remove resolve confict artifacts --- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 3 - .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 220 +++++++++--------- 2 files changed, 109 insertions(+), 114 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 3c70b200dbfd..82ac471132f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -41,11 +41,8 @@ import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.libanki.withCollapsedWhitespace import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar -<<<<<<< fix-scroll-thomb import com.ichi2.anki.ui.attachFastScroller -======= import com.ichi2.anki.utils.ext.requireParcelable ->>>>>>> main import com.ichi2.ui.AccessibleSearchView import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown import com.ichi2.utils.TagsUtil diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 0de1feeaceb3..49285b629b64 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -56,6 +56,7 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.annotation.IdRes +import androidx.annotation.VisibleForTesting import androidx.core.graphics.drawable.toDrawable import androidx.core.view.GravityCompat import androidx.interpolator.view.animation.FastOutLinearInInterpolator @@ -85,8 +86,6 @@ class RecyclerFastScroller private val minScrollHandleHeight: Int = 48.dp.toPx(context) var onHandleTouchListener: OnTouchListener? = null - private var appBarLayoutOffset: Int = 0 - private var recyclerView: RecyclerView? = null private var animator: AnimatorSet? = null @@ -107,69 +106,44 @@ class RecyclerFastScroller private var hideOverride = false private var adapter: RecyclerView.Adapter<*>? = null - private var cachedHandleHeight = 0 - private var cachedHandleHeightItemCount = RecyclerView.NO_POSITION - private var cachedHandleHeightBarHeight = 0 + // Item count on screen, cached so the thumb keeps a steady size while scrolling rows of + // different heights. 0 means "recompute on the next layout". Item count, bar height and + // width are the keys that invalidate it (width matters because rows can rewrap). private var cachedVisibleItemCount = 0 - private var cachedVisibleItemCountItemCount = RecyclerView.NO_POSITION - private var cachedVisibleItemCountBarHeight = 0 + private var cachedItemCount = RecyclerView.NO_POSITION + private var cachedBarHeight = 0 + private var cachedWidth = 0 - private fun resetCachedVisibleItemCount() { + private fun invalidateScrollMetrics() { cachedVisibleItemCount = 0 - cachedVisibleItemCountItemCount = RecyclerView.NO_POSITION - cachedVisibleItemCountBarHeight = 0 - } - - private fun resetCachedHandleHeight() { - cachedHandleHeight = 0 - cachedHandleHeightItemCount = RecyclerView.NO_POSITION - cachedHandleHeightBarHeight = 0 + cachedItemCount = RecyclerView.NO_POSITION + cachedBarHeight = 0 + cachedWidth = 0 } - /** - * Cached scroll metrics are tied to the current adapter contents and viewport size. - * They are reset when adapter data changes so the thumb can be recalculated for the - * new list, but stay stable during normal scrolling. - */ - private fun resetCachedScrollMetrics() { - resetCachedHandleHeight() - resetCachedVisibleItemCount() + private fun onAdapterDataChanged() { + invalidateScrollMetrics() + requestLayout() } private val adapterObserver: RecyclerView.AdapterDataObserver = object : RecyclerView.AdapterDataObserver() { - override fun onChanged() { - super.onChanged() - resetCachedScrollMetrics() - requestLayout() - } + override fun onChanged() = onAdapterDataChanged() override fun onItemRangeChanged( positionStart: Int, itemCount: Int, - ) { - super.onItemRangeChanged(positionStart, itemCount) - resetCachedScrollMetrics() - requestLayout() - } + ) = onAdapterDataChanged() override fun onItemRangeInserted( positionStart: Int, itemCount: Int, - ) { - super.onItemRangeInserted(positionStart, itemCount) - resetCachedScrollMetrics() - requestLayout() - } + ) = onAdapterDataChanged() override fun onItemRangeRemoved( positionStart: Int, itemCount: Int, - ) { - super.onItemRangeRemoved(positionStart, itemCount) - resetCachedScrollMetrics() - requestLayout() - } + ) = onAdapterDataChanged() } /** @@ -311,7 +285,7 @@ class RecyclerFastScroller this.adapter?.unregisterAdapterDataObserver(adapterObserver) adapter?.registerAdapterDataObserver(adapterObserver) this.adapter = adapter - resetCachedScrollMetrics() + invalidateScrollMetrics() } /** @@ -455,45 +429,51 @@ class RecyclerFastScroller super.onLayout(changed, left, top, right, bottom) val recyclerView = recyclerView ?: return + // The adapter can be set after we were attached, so make sure our data observer is + // registered. Without it the cached thumb size never refreshes on a data change. + if (recyclerView.adapter !== adapter) attachAdapter(recyclerView.adapter) + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return val itemCount = recyclerView.adapter?.itemCount ?: return - if (itemCount == 0) return + if (itemCount == 0) { + hideThumb() + return + } + + val barHeight = bar.height + if (barHeight == 0) return + + // The whole list fits, so there is nothing to scroll: keep the thumb hidden. + if (!recyclerView.canScrollVertically(-1) && !recyclerView.canScrollVertically(1)) { + hideThumb() + return + } + hideOverride = false val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() val lastVisiblePosition = layoutManager.findLastVisibleItemPosition() if (firstVisiblePosition == RecyclerView.NO_POSITION || lastVisiblePosition == RecyclerView.NO_POSITION) return - val barHeight = bar.height val visibleItemCount = - getCachedVisibleItemCount( - barHeight, - itemCount, - firstVisiblePosition, - lastVisiblePosition, - ) - val calculatedHandleHeight = getCachedHandleHeight(barHeight, itemCount, visibleItemCount) - - val ratio = - getScrollProportion( - recyclerView, - layoutManager, - itemCount, - visibleItemCount, - firstVisiblePosition, - ) + resolveVisibleItemCount(recyclerView, itemCount, barHeight, firstVisiblePosition, lastVisiblePosition) + val handleHeight = computeThumbHeight(barHeight, itemCount, visibleItemCount, minScrollHandleHeight) + val ratio = scrollProportion(recyclerView, layoutManager, itemCount, visibleItemCount, firstVisiblePosition) - hideOverride = false + val y = ratio * (barHeight - handleHeight) + handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + handleHeight) + } - val y = ratio * (barHeight - calculatedHandleHeight) - handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight) + // Slides the scroller off screen and stops show() bringing it back while nothing scrolls. + private fun hideThumb() { + translationX = hiddenTranslationX.toFloat() + hideOverride = true } /** - * Calculates thumb position from adapter positions so the scroll progress remains - * stable when rows have different heights. The fractional offset within the first - * visible row keeps the thumb moving smoothly between adapter positions. + * Thumb position from adapter positions, so progress stays steady when rows differ in + * height. The fraction scrolled through the first visible row keeps it moving smoothly. */ - private fun getScrollProportion( + private fun scrollProportion( recyclerView: RecyclerView, layoutManager: LinearLayoutManager, itemCount: Int, @@ -507,68 +487,86 @@ class RecyclerFastScroller val hiddenHeight = (recyclerView.paddingTop - firstVisibleView.top).coerceAtLeast(0) val firstItemFraction = hiddenHeight.toFloat() / firstVisibleView.height.coerceAtLeast(1) - val scrollableItems = (itemCount - visibleItemCount).coerceAtLeast(1) - return ((firstVisiblePosition + firstItemFraction) / scrollableItems).coerceIn(0f, 1f) + return computeScrollProportion(firstVisiblePosition, firstItemFraction, itemCount, visibleItemCount) } /** - * Keeps the visible item count stable during scrolling. Recomputing it for every - * layout pass could change the thumb size as rows with different heights enter or - * leave the viewport. + * Items on screen, measured once and cached. Averaging the heights of the rows currently + * laid out is steadier than counting partly visible ones, and caching stops the thumb + * resizing as rows of different heights scroll past. The count is frozen per list, so on + * wildly varying rows the position can be slightly off near the very end. That is the + * price we pay for a thumb that does not resize while scrolling. */ - private fun getCachedVisibleItemCount( - barHeight: Int, + private fun resolveVisibleItemCount( + recyclerView: RecyclerView, itemCount: Int, + barHeight: Int, firstVisiblePosition: Int, lastVisiblePosition: Int, ): Int { - if (itemCount != cachedVisibleItemCountItemCount || barHeight != cachedVisibleItemCountBarHeight) { - resetCachedVisibleItemCount() - cachedVisibleItemCountItemCount = itemCount - cachedVisibleItemCountBarHeight = barHeight + val width = recyclerView.width + if (itemCount != cachedItemCount || barHeight != cachedBarHeight || width != cachedWidth) { + cachedVisibleItemCount = 0 + cachedItemCount = itemCount + cachedBarHeight = barHeight + cachedWidth = width } if (cachedVisibleItemCount == 0) { - cachedVisibleItemCount = - (lastVisiblePosition - firstVisiblePosition + 1) - .coerceAtLeast(1) - .coerceAtMost(itemCount) + val childCount = recyclerView.childCount + var totalChildHeight = 0 + for (i in 0 until childCount) { + totalChildHeight += recyclerView.getChildAt(i)?.height ?: 0 + } + val averageChildHeight = if (childCount > 0) totalChildHeight / childCount else 0 + val estimate = + if (averageChildHeight > 0) { + barHeight / averageChildHeight + } else { + lastVisiblePosition - firstVisiblePosition + 1 + } + cachedVisibleItemCount = estimate.coerceIn(1, itemCount) } return cachedVisibleItemCount } - /** - * Calculates thumb height from the visible item count and total item count so the - * size remains stable while scrolling through rows with different heights. - */ - private fun getCachedHandleHeight( - barHeight: Int, - itemCount: Int, - visibleItemCount: Int, - ): Int { - if (itemCount != cachedHandleHeightItemCount || barHeight != cachedHandleHeightBarHeight) { - resetCachedHandleHeight() - cachedHandleHeightItemCount = itemCount - cachedHandleHeightBarHeight = barHeight - } - - if (cachedHandleHeight == 0) { - cachedHandleHeight = - (barHeight.toFloat() * visibleItemCount / itemCount) - .toInt() - .coerceAtLeast(minScrollHandleHeight) - .coerceAtMost(barHeight) - } - - return cachedHandleHeight - } - companion object { private val DEFAULT_AUTO_HIDE_DELAY = 1500.milliseconds } } +/** + * Thumb height from the share of items on screen, clamped to a usable range. It does not depend + * on scroll position, which is what stops the thumb from resizing while scrolling. + */ +@VisibleForTesting +internal fun computeThumbHeight( + barHeight: Int, + itemCount: Int, + visibleItemCount: Int, + minHandleHeight: Int, +): Int = + (barHeight.toFloat() * visibleItemCount / itemCount) + .toInt() + .coerceAtLeast(minHandleHeight) + .coerceAtMost(barHeight) + +/** + * Scroll progress from 0f to 1f, built from the first visible position plus how far it has + * scrolled out of view. Guards against a zero divisor when the list barely scrolls. + */ +@VisibleForTesting +internal fun computeScrollProportion( + firstVisiblePosition: Int, + firstItemFraction: Float, + itemCount: Int, + visibleItemCount: Int, +): Float { + val scrollableItems = (itemCount - visibleItemCount).coerceAtLeast(1) + return ((firstVisiblePosition + firstItemFraction) / scrollableItems).coerceIn(0f, 1f) +} + fun RecyclerView.attachFastScroller( @IdRes id: Int, ) = (parent as ViewGroup) From 4a38ab95ce3b9e989fcdea4f74b30b175ba9097d Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Mon, 13 Jul 2026 18:14:46 +0500 Subject: [PATCH 09/14] null safe to attachFastScroller --- .../main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 49285b629b64..1e68117527e4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -569,6 +569,8 @@ internal fun computeScrollProportion( fun RecyclerView.attachFastScroller( @IdRes id: Int, -) = (parent as ViewGroup) - .findViewById(id) - .attachRecyclerView(this) +) { + (parent as? ViewGroup) + ?.findViewById(id) + ?.attachRecyclerView(this) +} From 680e7057f4f8aea9b0b4f9007621242467c20747 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Tue, 14 Jul 2026 10:30:44 +0500 Subject: [PATCH 10/14] apply criticalAY patch 2 --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 107 ++++++------------ .../ichi2/anki/ui/RecyclerFastScrollerTest.kt | 62 ++++++++++ 2 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 1e68117527e4..ea14ad08b74b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -106,16 +106,17 @@ class RecyclerFastScroller private var hideOverride = false private var adapter: RecyclerView.Adapter<*>? = null - // Item count on screen, cached so the thumb keeps a steady size while scrolling rows of - // different heights. 0 means "recompute on the next layout". Item count, bar height and - // width are the keys that invalidate it (width matters because rows can rewrap). - private var cachedVisibleItemCount = 0 + // Thumb height, cached so it keeps a steady size while scrolling rows of different + // heights. Only the size ever flickered, so only the size is frozen. 0 means "recompute + // on the next layout". Item count, bar height and width invalidate it (width because rows + // can rewrap and change the total scroll range). + private var cachedHandleHeight = 0 private var cachedItemCount = RecyclerView.NO_POSITION private var cachedBarHeight = 0 private var cachedWidth = 0 private fun invalidateScrollMetrics() { - cachedVisibleItemCount = 0 + cachedHandleHeight = 0 cachedItemCount = RecyclerView.NO_POSITION cachedBarHeight = 0 cachedWidth = 0 @@ -433,7 +434,6 @@ class RecyclerFastScroller // registered. Without it the cached thumb size never refreshes on a data change. if (recyclerView.adapter !== adapter) attachAdapter(recyclerView.adapter) - val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return val itemCount = recyclerView.adapter?.itemCount ?: return if (itemCount == 0) { hideThumb() @@ -450,14 +450,13 @@ class RecyclerFastScroller } hideOverride = false - val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() - val lastVisiblePosition = layoutManager.findLastVisibleItemPosition() - if (firstVisiblePosition == RecyclerView.NO_POSITION || lastVisiblePosition == RecyclerView.NO_POSITION) return + val scrollRange = recyclerView.computeVerticalScrollRange() + recyclerView.paddingBottom + if (scrollRange <= barHeight) return - val visibleItemCount = - resolveVisibleItemCount(recyclerView, itemCount, barHeight, firstVisiblePosition, lastVisiblePosition) - val handleHeight = computeThumbHeight(barHeight, itemCount, visibleItemCount, minScrollHandleHeight) - val ratio = scrollProportion(recyclerView, layoutManager, itemCount, visibleItemCount, firstVisiblePosition) + val handleHeight = resolveHandleHeight(itemCount, barHeight, recyclerView.width, scrollRange) + // Position is measured in pixels, so the thumb tracks the scroll smoothly even when + // rows have different heights. Only the size is frozen, since only the size flickered. + val ratio = computeScrollProportion(recyclerView.computeVerticalScrollOffset(), scrollRange, barHeight) val y = ratio * (barHeight - handleHeight) handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + handleHeight) @@ -470,65 +469,29 @@ class RecyclerFastScroller } /** - * Thumb position from adapter positions, so progress stays steady when rows differ in - * height. The fraction scrolled through the first visible row keeps it moving smoothly. + * Thumb height for the current list, computed once and cached. The scroll range is a live + * estimate that drifts while scrolling, and it is only the size that drifted, so we freeze + * the size and let the position stay live. Recomputed when the data, bar height or width + * change (see [invalidateScrollMetrics]). */ - private fun scrollProportion( - recyclerView: RecyclerView, - layoutManager: LinearLayoutManager, - itemCount: Int, - visibleItemCount: Int, - firstVisiblePosition: Int, - ): Float { - if (!recyclerView.canScrollVertically(-1)) return 0f - if (!recyclerView.canScrollVertically(1)) return 1f - - val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition) ?: return 0f - val hiddenHeight = (recyclerView.paddingTop - firstVisibleView.top).coerceAtLeast(0) - val firstItemFraction = hiddenHeight.toFloat() / firstVisibleView.height.coerceAtLeast(1) - - return computeScrollProportion(firstVisiblePosition, firstItemFraction, itemCount, visibleItemCount) - } - - /** - * Items on screen, measured once and cached. Averaging the heights of the rows currently - * laid out is steadier than counting partly visible ones, and caching stops the thumb - * resizing as rows of different heights scroll past. The count is frozen per list, so on - * wildly varying rows the position can be slightly off near the very end. That is the - * price we pay for a thumb that does not resize while scrolling. - */ - private fun resolveVisibleItemCount( - recyclerView: RecyclerView, + private fun resolveHandleHeight( itemCount: Int, barHeight: Int, - firstVisiblePosition: Int, - lastVisiblePosition: Int, + width: Int, + scrollRange: Int, ): Int { - val width = recyclerView.width if (itemCount != cachedItemCount || barHeight != cachedBarHeight || width != cachedWidth) { - cachedVisibleItemCount = 0 + cachedHandleHeight = 0 cachedItemCount = itemCount cachedBarHeight = barHeight cachedWidth = width } - if (cachedVisibleItemCount == 0) { - val childCount = recyclerView.childCount - var totalChildHeight = 0 - for (i in 0 until childCount) { - totalChildHeight += recyclerView.getChildAt(i)?.height ?: 0 - } - val averageChildHeight = if (childCount > 0) totalChildHeight / childCount else 0 - val estimate = - if (averageChildHeight > 0) { - barHeight / averageChildHeight - } else { - lastVisiblePosition - firstVisiblePosition + 1 - } - cachedVisibleItemCount = estimate.coerceIn(1, itemCount) + if (cachedHandleHeight == 0) { + cachedHandleHeight = computeThumbHeight(barHeight, scrollRange, minScrollHandleHeight) } - return cachedVisibleItemCount + return cachedHandleHeight } companion object { @@ -537,34 +500,32 @@ class RecyclerFastScroller } /** - * Thumb height from the share of items on screen, clamped to a usable range. It does not depend - * on scroll position, which is what stops the thumb from resizing while scrolling. + * Thumb height as the share of the content that fits on screen (bar height over the scroll + * range), clamped to a usable range. Independent of scroll position, so a cached value stays valid. */ @VisibleForTesting internal fun computeThumbHeight( barHeight: Int, - itemCount: Int, - visibleItemCount: Int, + scrollRange: Int, minHandleHeight: Int, ): Int = - (barHeight.toFloat() * visibleItemCount / itemCount) + (barHeight.toFloat() * barHeight / scrollRange.coerceAtLeast(1)) .toInt() .coerceAtLeast(minHandleHeight) .coerceAtMost(barHeight) /** - * Scroll progress from 0f to 1f, built from the first visible position plus how far it has - * scrolled out of view. Guards against a zero divisor when the list barely scrolls. + * Scroll progress from 0f to 1f, measured in pixels so the thumb tracks the scroll smoothly on + * rows of different heights. Guards against a zero divisor when the list barely scrolls. */ @VisibleForTesting internal fun computeScrollProportion( - firstVisiblePosition: Int, - firstItemFraction: Float, - itemCount: Int, - visibleItemCount: Int, + scrollOffset: Int, + scrollRange: Int, + barHeight: Int, ): Float { - val scrollableItems = (itemCount - visibleItemCount).coerceAtLeast(1) - return ((firstVisiblePosition + firstItemFraction) / scrollableItems).coerceIn(0f, 1f) + val scrollablePixels = (scrollRange - barHeight).coerceAtLeast(1) + return (scrollOffset.toFloat() / scrollablePixels).coerceIn(0f, 1f) } fun RecyclerView.attachFastScroller( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt new file mode 100644 index 000000000000..4ed0f16c7617 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Ashish Yadav +// SPDX-License-Identifier: GPL-3.0-or-later +package com.ichi2.anki.ui + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.closeTo +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.lessThan +import org.junit.Test + +class RecyclerFastScrollerTest { + @Test + fun `thumb height is the share of the content that fits on screen`() { + // a quarter of the content is visible, so the thumb is a quarter of the bar + assertThat(computeThumbHeight(barHeight = 1000, scrollRange = 4000, minHandleHeight = 48), equalTo(250)) + } + + @Test + fun `thumb height never drops below the minimum`() { + assertThat(computeThumbHeight(barHeight = 1000, scrollRange = 100_000, minHandleHeight = 48), equalTo(48)) + } + + @Test + fun `thumb height never exceeds the bar`() { + assertThat(computeThumbHeight(barHeight = 1000, scrollRange = 1000, minHandleHeight = 48), equalTo(1000)) + } + + @Test + fun `thumb height stays within the bar even when the minimum is taller than the bar`() { + assertThat(computeThumbHeight(barHeight = 30, scrollRange = 100_000, minHandleHeight = 48), equalTo(30)) + } + + @Test + fun `proportion is zero at the top of the list`() { + assertThat(computeScrollProportion(scrollOffset = 0, scrollRange = 4000, barHeight = 1000), equalTo(0f)) + } + + @Test + fun `proportion reaches one at the bottom of the list`() { + // the last screen starts at scrollRange - barHeight + assertThat(computeScrollProportion(scrollOffset = 3000, scrollRange = 4000, barHeight = 1000), equalTo(1f)) + } + + @Test + fun `proportion tracks scrolled pixels linearly`() { + // half the scrollable pixels means the thumb is at the middle: this is what keeps it + // smooth on uneven rows, since it follows pixels rather than item index + assertThat(computeScrollProportion(scrollOffset = 1500, scrollRange = 4000, barHeight = 1000).toDouble(), closeTo(0.5, 0.0001)) + assertThat(computeScrollProportion(scrollOffset = 750, scrollRange = 4000, barHeight = 1000).toDouble(), closeTo(0.25, 0.0001)) + } + + @Test + fun `proportion is clamped into range`() { + assertThat(computeScrollProportion(scrollOffset = 9999, scrollRange = 4000, barHeight = 1000), equalTo(1f)) + } + + @Test + fun `proportion does not divide by zero when the list barely scrolls`() { + // scrollRange == barHeight would be a zero divisor without the guard + assertThat(computeScrollProportion(scrollOffset = 0, scrollRange = 1000, barHeight = 1000), lessThan(1.0001f)) + } +} From bbef5ae12d6eee35ba99c63102c2414fb34a1da1 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Tue, 14 Jul 2026 11:02:50 +0500 Subject: [PATCH 11/14] cache scroll offset and range --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 126 +++++++++++++++--- .../ichi2/anki/ui/RecyclerFastScrollerTest.kt | 62 +++++++++ 2 files changed, 173 insertions(+), 15 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index ea14ad08b74b..b2741d2f9b9a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -106,20 +106,27 @@ class RecyclerFastScroller private var hideOverride = false private var adapter: RecyclerView.Adapter<*>? = null - // Thumb height, cached so it keeps a steady size while scrolling rows of different - // heights. Only the size ever flickered, so only the size is frozen. 0 means "recompute - // on the next layout". Item count, bar height and width invalidate it (width because rows - // can rewrap and change the total scroll range). + // Thumb height and range, cached so the handle keeps a steady size and scale while + // scrolling rows of different heights. 0 means "recompute on the next layout". Item count, + // bar height and width invalidate it (width because rows can rewrap and change the total + // scroll range). private var cachedHandleHeight = 0 + private var cachedScrollRange = 0 private var cachedItemCount = RecyclerView.NO_POSITION private var cachedBarHeight = 0 private var cachedWidth = 0 + private var accumulatedScrollOffset = 0 + private var scrollOffsetInitialized = false + private var isDraggingHandle = false private fun invalidateScrollMetrics() { cachedHandleHeight = 0 + cachedScrollRange = 0 cachedItemCount = RecyclerView.NO_POSITION cachedBarHeight = 0 cachedWidth = 0 + accumulatedScrollOffset = 0 + scrollOffsetInitialized = false } private fun onAdapterDataChanged() { @@ -274,6 +281,10 @@ class RecyclerFastScroller dy: Int, ) { super.onScrolled(recyclerView, dx, dy) + if (!isDraggingHandle) { + val scrollablePixels = resolveScrollablePixels(recyclerView) + updateAccumulatedScrollOffset(recyclerView, dy, scrollablePixels) + } this@RecyclerFastScroller.show(true) } }, @@ -377,6 +388,7 @@ class RecyclerFastScroller // Force the handle to be selected since the user is touching the track (the parent container) and not the handle itself. handle.isPressed = true + isDraggingHandle = true // The valid scroll area is (height-handle.height), since the position of the handle is defined by it's top edge, we subtract it. val scrollableHeight = height - handle.height @@ -387,6 +399,11 @@ class RecyclerFastScroller ((event.y - handle.height / 2) / scrollableHeight.coerceAtLeast(1)) .coerceIn(0f, 1f) pendingScrollProportion = scrollProportion + recyclerView?.let { + val scrollablePixels = resolveScrollablePixels(it) + accumulatedScrollOffset = (scrollProportion * scrollablePixels).toInt().coerceIn(0, scrollablePixels) + scrollOffsetInitialized = scrollablePixels > 0 + } // Calculates the item index we want to go to by multiplying our ScrollProportion to the item count // e.g. if we are going to 50% then 0.5*itemcount gives us the index we need. // toInt prevents decimal values, and coerceIn here makes it so when we scroll all the way to the end, we don't get an out of bounds error. @@ -414,6 +431,8 @@ class RecyclerFastScroller recyclerView?.let { removeCallbacks(scrollTask) } scrollTask.run() } + handle.isPressed = false + isDraggingHandle = false false } else -> super.onTouchEvent(event) @@ -450,13 +469,17 @@ class RecyclerFastScroller } hideOverride = false - val scrollRange = recyclerView.computeVerticalScrollRange() + recyclerView.paddingBottom + val measuredScrollRange = recyclerView.computeVerticalScrollRange() + recyclerView.paddingBottom + val scrollRange = resolveScrollRange(itemCount, barHeight, recyclerView.width, measuredScrollRange) if (scrollRange <= barHeight) return - val handleHeight = resolveHandleHeight(itemCount, barHeight, recyclerView.width, scrollRange) - // Position is measured in pixels, so the thumb tracks the scroll smoothly even when - // rows have different heights. Only the size is frozen, since only the size flickered. - val ratio = computeScrollProportion(recyclerView.computeVerticalScrollOffset(), scrollRange, barHeight) + val scrollablePixels = scrollRange - barHeight + updateAccumulatedScrollOffset(recyclerView, dy = 0, scrollablePixels) + + val handleHeight = resolveHandleHeight(barHeight, scrollRange) + // RecyclerView's scrollbar range is an estimate for variable-height rows, so keep it + // as a stable scale while the position follows real scroll deltas. + val ratio = computeScrollProportion(accumulatedScrollOffset, scrollRange, barHeight) val y = ratio * (barHeight - handleHeight) handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + handleHeight) @@ -468,13 +491,57 @@ class RecyclerFastScroller hideOverride = true } + private fun resolveScrollablePixels(recyclerView: RecyclerView): Int { + val itemCount = recyclerView.adapter?.itemCount ?: return 0 + val barHeight = bar.height + if (itemCount == 0 || barHeight == 0) return 0 + + val measuredScrollRange = recyclerView.computeVerticalScrollRange() + recyclerView.paddingBottom + val scrollRange = resolveScrollRange(itemCount, barHeight, recyclerView.width, measuredScrollRange) + return (scrollRange - barHeight).coerceAtLeast(0) + } + + private fun updateAccumulatedScrollOffset( + recyclerView: RecyclerView, + dy: Int, + scrollablePixels: Int, + ) { + if (scrollablePixels <= 0) { + accumulatedScrollOffset = 0 + scrollOffsetInitialized = false + return + } + + if (!scrollOffsetInitialized) { + accumulatedScrollOffset = recyclerView.computeVerticalScrollOffset().coerceIn(0, scrollablePixels) + scrollOffsetInitialized = true + accumulatedScrollOffset = + computeScrollOffsetFromDelta( + currentOffset = accumulatedScrollOffset, + dy = 0, + scrollablePixels = scrollablePixels, + canScrollUp = recyclerView.canScrollVertically(-1), + canScrollDown = recyclerView.canScrollVertically(1), + ) + return + } + + accumulatedScrollOffset = + computeScrollOffsetFromDelta( + currentOffset = accumulatedScrollOffset, + dy = dy, + scrollablePixels = scrollablePixels, + canScrollUp = recyclerView.canScrollVertically(-1), + canScrollDown = recyclerView.canScrollVertically(1), + ) + } + /** - * Thumb height for the current list, computed once and cached. The scroll range is a live - * estimate that drifts while scrolling, and it is only the size that drifted, so we freeze - * the size and let the position stay live. Recomputed when the data, bar height or width - * change (see [invalidateScrollMetrics]). + * Scroll range for the current list, computed once and cached. RecyclerView estimates it + * from currently visible rows, so it can drift while scrolling variable-height rows. + * Recomputed when the data, bar height or width change (see [invalidateScrollMetrics]). */ - private fun resolveHandleHeight( + private fun resolveScrollRange( itemCount: Int, barHeight: Int, width: Int, @@ -482,15 +549,28 @@ class RecyclerFastScroller ): Int { if (itemCount != cachedItemCount || barHeight != cachedBarHeight || width != cachedWidth) { cachedHandleHeight = 0 + cachedScrollRange = 0 cachedItemCount = itemCount cachedBarHeight = barHeight cachedWidth = width + accumulatedScrollOffset = 0 + scrollOffsetInitialized = false + } + + if (cachedScrollRange == 0) { + cachedScrollRange = scrollRange } + return cachedScrollRange + } + + private fun resolveHandleHeight( + barHeight: Int, + scrollRange: Int, + ): Int { if (cachedHandleHeight == 0) { cachedHandleHeight = computeThumbHeight(barHeight, scrollRange, minScrollHandleHeight) } - return cachedHandleHeight } @@ -528,6 +608,22 @@ internal fun computeScrollProportion( return (scrollOffset.toFloat() / scrollablePixels).coerceIn(0f, 1f) } +@VisibleForTesting +internal fun computeScrollOffsetFromDelta( + currentOffset: Int, + dy: Int, + scrollablePixels: Int, + canScrollUp: Boolean, + canScrollDown: Boolean, +): Int { + if (scrollablePixels <= 0) return 0 + return when { + !canScrollUp -> 0 + !canScrollDown -> scrollablePixels + else -> (currentOffset + dy).coerceIn(0, scrollablePixels) + } +} + fun RecyclerView.attachFastScroller( @IdRes id: Int, ) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt index 4ed0f16c7617..8971af4a82b8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt @@ -59,4 +59,66 @@ class RecyclerFastScrollerTest { // scrollRange == barHeight would be a zero divisor without the guard assertThat(computeScrollProportion(scrollOffset = 0, scrollRange = 1000, barHeight = 1000), lessThan(1.0001f)) } + + @Test + fun `scroll offset accumulates real pixel deltas`() { + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 100, + dy = 25, + scrollablePixels = 1000, + canScrollUp = true, + canScrollDown = true, + ), + equalTo(125), + ) + } + + @Test + fun `scroll offset is clamped into the scrollable range`() { + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 995, + dy = 20, + scrollablePixels = 1000, + canScrollUp = true, + canScrollDown = true, + ), + equalTo(1000), + ) + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 5, + dy = -20, + scrollablePixels = 1000, + canScrollUp = true, + canScrollDown = true, + ), + equalTo(0), + ) + } + + @Test + fun `scroll offset snaps to exact list edges`() { + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 100, + dy = 25, + scrollablePixels = 1000, + canScrollUp = false, + canScrollDown = true, + ), + equalTo(0), + ) + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 100, + dy = 25, + scrollablePixels = 1000, + canScrollUp = true, + canScrollDown = false, + ), + equalTo(1000), + ) + } } From 070a5235185a355b0bddfbd42c18f6af9955f611 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Tue, 14 Jul 2026 11:21:38 +0500 Subject: [PATCH 12/14] add more comments --- .../main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index b2741d2f9b9a..57a603d7bfb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -134,6 +134,9 @@ class RecyclerFastScroller requestLayout() } + // We cache scroll thumb params to make it smooth on variable-height rows, + // since RecyclerView's scrollbar range is only an estimate. + // We update the cached values when the adapter changes or the bar height/width changes. private val adapterObserver: RecyclerView.AdapterDataObserver = object : RecyclerView.AdapterDataObserver() { override fun onChanged() = onAdapterDataChanged() @@ -281,6 +284,8 @@ class RecyclerFastScroller dy: Int, ) { super.onScrolled(recyclerView, dx, dy) + // Track normal list scrolling from real pixel deltas. While the handle is being dragged, + // the offset is set from the drag position instead, so do not apply RecyclerView's dy too. if (!isDraggingHandle) { val scrollablePixels = resolveScrollablePixels(recyclerView) updateAccumulatedScrollOffset(recyclerView, dy, scrollablePixels) @@ -617,6 +622,8 @@ internal fun computeScrollOffsetFromDelta( canScrollDown: Boolean, ): Int { if (scrollablePixels <= 0) return 0 + // Snap to exact edges so small accumulated-delta drift cannot leave the thumb + // slightly away from the top or bottom. return when { !canScrollUp -> 0 !canScrollDown -> scrollablePixels From 16c681cb6bd256f6ae175e9265ad902ce23e01fb Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Wed, 15 Jul 2026 09:21:54 +0500 Subject: [PATCH 13/14] add scroll calibration to make scroll in the end correct and smooth --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 142 ++++++++++++++---- .../ichi2/anki/ui/RecyclerFastScrollerTest.kt | 121 ++++++++++++--- 2 files changed, 215 insertions(+), 48 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 57a603d7bfb4..ef02a484e7c8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -54,6 +54,7 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.animation.LinearInterpolator import android.widget.FrameLayout import androidx.annotation.IdRes import androidx.annotation.VisibleForTesting @@ -117,7 +118,13 @@ class RecyclerFastScroller private var cachedWidth = 0 private var accumulatedScrollOffset = 0 private var scrollOffsetInitialized = false + private var scrollRangeCalibrated = false + private var canCalibrateScrollRange = false private var isDraggingHandle = false + private var handlePositionInitialized = false + private var wasAtBottom = false + private var isAnimatingHandleToBottom = false + private var handleAnimationTargetY = 0f private fun invalidateScrollMetrics() { cachedHandleHeight = 0 @@ -127,6 +134,11 @@ class RecyclerFastScroller cachedWidth = 0 accumulatedScrollOffset = 0 scrollOffsetInitialized = false + scrollRangeCalibrated = false + canCalibrateScrollRange = false + handlePositionInitialized = false + wasAtBottom = false + isAnimatingHandleToBottom = false } private fun onAdapterDataChanged() { @@ -394,6 +406,7 @@ class RecyclerFastScroller // Force the handle to be selected since the user is touching the track (the parent container) and not the handle itself. handle.isPressed = true isDraggingHandle = true + canCalibrateScrollRange = false // The valid scroll area is (height-handle.height), since the position of the handle is defined by it's top edge, we subtract it. val scrollableHeight = height - handle.height @@ -482,12 +495,56 @@ class RecyclerFastScroller updateAccumulatedScrollOffset(recyclerView, dy = 0, scrollablePixels) val handleHeight = resolveHandleHeight(barHeight, scrollRange) - // RecyclerView's scrollbar range is an estimate for variable-height rows, so keep it - // as a stable scale while the position follows real scroll deltas. - val ratio = computeScrollProportion(accumulatedScrollOffset, scrollRange, barHeight) + val isAtBottom = !recyclerView.canScrollVertically(1) + val ratio = + computeDisplayScrollProportion( + scrollOffset = accumulatedScrollOffset, + scrollRange = cachedScrollRange, + barHeight = barHeight, + canScrollDown = !isAtBottom, + rangeCalibrated = scrollRangeCalibrated, + ) val y = ratio * (barHeight - handleHeight) - handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + handleHeight) + val animateToBottom = + shouldAnimateHandleToBottom( + wasAtBottom = wasAtBottom, + isAtBottom = isAtBottom, + isDraggingHandle = isDraggingHandle, + handlePositionInitialized = handlePositionInitialized, + ) + layoutHandle(y, handleHeight, animateToBottom) + wasAtBottom = isAtBottom + } + + private fun layoutHandle( + targetY: Float, + handleHeight: Int, + animateToBottom: Boolean, + ) { + handle.layout(handle.left, 0, handle.right, handleHeight) + + if (isAnimatingHandleToBottom && handleAnimationTargetY == targetY) return + + if (!handlePositionInitialized || isDraggingHandle || !animateToBottom) { + handle.animate().cancel() + isAnimatingHandleToBottom = false + handle.translationY = targetY + handlePositionInitialized = true + return + } + + if (handle.translationY == targetY) return + handle.animate().cancel() + isAnimatingHandleToBottom = true + handleAnimationTargetY = targetY + handle + .animate() + .translationY(targetY) + .setDuration(HANDLE_POSITION_ANIMATION_DURATION_MS) + .setInterpolator(LinearInterpolator()) + .withEndAction { isAnimatingHandleToBottom = false } + .start() } // Slides the scroller off screen and stops show() bringing it back while nothing scrolls. @@ -518,27 +575,24 @@ class RecyclerFastScroller } if (!scrollOffsetInitialized) { - accumulatedScrollOffset = recyclerView.computeVerticalScrollOffset().coerceIn(0, scrollablePixels) + accumulatedScrollOffset = recyclerView.computeVerticalScrollOffset().coerceAtLeast(0) scrollOffsetInitialized = true + } else { accumulatedScrollOffset = computeScrollOffsetFromDelta( currentOffset = accumulatedScrollOffset, - dy = 0, - scrollablePixels = scrollablePixels, + dy = dy, canScrollUp = recyclerView.canScrollVertically(-1), - canScrollDown = recyclerView.canScrollVertically(1), ) - return } - accumulatedScrollOffset = - computeScrollOffsetFromDelta( - currentOffset = accumulatedScrollOffset, - dy = dy, - scrollablePixels = scrollablePixels, - canScrollUp = recyclerView.canScrollVertically(-1), - canScrollDown = recyclerView.canScrollVertically(1), - ) + if (!recyclerView.canScrollVertically(-1)) { + accumulatedScrollOffset = 0 + canCalibrateScrollRange = !isDraggingHandle + } else if (!recyclerView.canScrollVertically(1) && canCalibrateScrollRange) { + cachedScrollRange = accumulatedScrollOffset + bar.height + scrollRangeCalibrated = true + } } /** @@ -560,6 +614,11 @@ class RecyclerFastScroller cachedWidth = width accumulatedScrollOffset = 0 scrollOffsetInitialized = false + scrollRangeCalibrated = false + canCalibrateScrollRange = false + handlePositionInitialized = false + wasAtBottom = false + isAnimatingHandleToBottom = false } if (cachedScrollRange == 0) { @@ -580,6 +639,7 @@ class RecyclerFastScroller } companion object { + private const val HANDLE_POSITION_ANIMATION_DURATION_MS = 100L private val DEFAULT_AUTO_HIDE_DELAY = 1500.milliseconds } } @@ -617,20 +677,48 @@ internal fun computeScrollProportion( internal fun computeScrollOffsetFromDelta( currentOffset: Int, dy: Int, - scrollablePixels: Int, canScrollUp: Boolean, - canScrollDown: Boolean, ): Int { - if (scrollablePixels <= 0) return 0 - // Snap to exact edges so small accumulated-delta drift cannot leave the thumb - // slightly away from the top or bottom. - return when { - !canScrollUp -> 0 - !canScrollDown -> scrollablePixels - else -> (currentOffset + dy).coerceIn(0, scrollablePixels) - } + if (!canScrollUp) return 0 + return (currentOffset + dy).coerceAtLeast(0) } +/** + * Maps an uncalibrated range smoothly toward, but never onto, the end of the track. RecyclerView's + * first range estimate can be shorter than the real content, so a linear mapping would put the + * thumb at the bottom too early. Once a complete top-to-bottom scroll calibrates the range, the + * regular linear mapping is exact. + */ +@VisibleForTesting +internal fun computeDisplayScrollProportion( + scrollOffset: Int, + scrollRange: Int, + barHeight: Int, + canScrollDown: Boolean, + rangeCalibrated: Boolean, +): Float { + if (!canScrollDown) return 1f + + val scrollablePixels = (scrollRange - barHeight).coerceAtLeast(1) + val rawProportion = (scrollOffset.toFloat() / scrollablePixels).coerceAtLeast(0f) + if (rangeCalibrated) return computeScrollProportion(scrollOffset, scrollRange, barHeight) + if (rawProportion <= END_APPROACH_THRESHOLD) return rawProportion + + val tail = 1f - END_APPROACH_THRESHOLD + val excess = (rawProportion - END_APPROACH_THRESHOLD) / tail + return END_APPROACH_THRESHOLD + tail * excess / (1f + excess) +} + +private const val END_APPROACH_THRESHOLD = 0.9f + +@VisibleForTesting +internal fun shouldAnimateHandleToBottom( + wasAtBottom: Boolean, + isAtBottom: Boolean, + isDraggingHandle: Boolean, + handlePositionInitialized: Boolean, +): Boolean = handlePositionInitialized && !isDraggingHandle && !wasAtBottom && isAtBottom + fun RecyclerView.attachFastScroller( @IdRes id: Int, ) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt index 8971af4a82b8..b7a9c759921e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt @@ -5,6 +5,7 @@ package com.ichi2.anki.ui import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.closeTo import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.greaterThan import org.hamcrest.Matchers.lessThan import org.junit.Test @@ -66,59 +67,137 @@ class RecyclerFastScrollerTest { computeScrollOffsetFromDelta( currentOffset = 100, dy = 25, - scrollablePixels = 1000, canScrollUp = true, - canScrollDown = true, ), equalTo(125), ) } @Test - fun `scroll offset is clamped into the scrollable range`() { + fun `scroll offset can outgrow an estimate while the list can still scroll`() { assertThat( computeScrollOffsetFromDelta( currentOffset = 995, dy = 20, - scrollablePixels = 1000, canScrollUp = true, - canScrollDown = true, ), - equalTo(1000), + equalTo(1015), ) + } + + @Test + fun `scroll offset is clamped at the start of the list`() { assertThat( computeScrollOffsetFromDelta( currentOffset = 5, dy = -20, - scrollablePixels = 1000, canScrollUp = true, - canScrollDown = true, ), equalTo(0), ) } @Test - fun `scroll offset snaps to exact list edges`() { + fun `thumb does not reach the end while the list can still scroll`() { assertThat( - computeScrollOffsetFromDelta( - currentOffset = 100, - dy = 25, - scrollablePixels = 1000, - canScrollUp = false, + computeDisplayScrollProportion( + scrollOffset = 3015, + scrollRange = 4000, + barHeight = 1000, canScrollDown = true, + rangeCalibrated = false, ), - equalTo(0), + lessThan(1f), ) + } + + @Test + fun `thumb continues moving smoothly after passing the estimated range`() { + val atEstimate = + computeDisplayScrollProportion( + scrollOffset = 3000, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = false, + ) + val pastEstimate = + computeDisplayScrollProportion( + scrollOffset = 4500, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = false, + ) + + assertThat(pastEstimate, greaterThan(atEstimate)) + assertThat(pastEstimate, lessThan(1f)) + } + + @Test + fun `thumb reaches the end only at the real list edge`() { assertThat( - computeScrollOffsetFromDelta( - currentOffset = 100, - dy = 25, - scrollablePixels = 1000, - canScrollUp = true, + computeDisplayScrollProportion( + scrollOffset = 4500, + scrollRange = 4000, + barHeight = 1000, canScrollDown = false, + rangeCalibrated = false, + ), + equalTo(1f), + ) + } + + @Test + fun `calibrated range uses linear pixel progress`() { + assertThat( + computeDisplayScrollProportion( + scrollOffset = 1500, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = true, + ), + equalTo(0.5f), + ) + } + + @Test + fun `scroll offset snaps to the start of the list`() { + assertThat( + computeScrollOffsetFromDelta(currentOffset = 100, dy = 25, canScrollUp = false), + equalTo(0), + ) + } + + @Test + fun `handle animation is used only when normal scrolling reaches the bottom`() { + assertThat( + shouldAnimateHandleToBottom( + wasAtBottom = false, + isAtBottom = true, + isDraggingHandle = false, + handlePositionInitialized = true, + ), + equalTo(true), + ) + assertThat( + shouldAnimateHandleToBottom( + wasAtBottom = false, + isAtBottom = false, + isDraggingHandle = false, + handlePositionInitialized = true, + ), + equalTo(false), + ) + assertThat( + shouldAnimateHandleToBottom( + wasAtBottom = false, + isAtBottom = true, + isDraggingHandle = true, + handlePositionInitialized = true, ), - equalTo(1000), + equalTo(false), ) } } From a34c549f148565c2b2d962b7d872b2d68f2dd726 Mon Sep 17 00:00:00 2001 From: Svetlana Ivanova Date: Wed, 15 Jul 2026 09:41:45 +0500 Subject: [PATCH 14/14] update only on content change, ignore itemrangechange (fixes scroll thumb size change on tap to open menu) --- .../src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index ef02a484e7c8..b99805581b5e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -153,10 +153,8 @@ class RecyclerFastScroller object : RecyclerView.AdapterDataObserver() { override fun onChanged() = onAdapterDataChanged() - override fun onItemRangeChanged( - positionStart: Int, - itemCount: Int, - ) = onAdapterDataChanged() + // Content-only row updates, such as focus or selection changes, intentionally use + // the default no-op callback so they cannot move or resize the scroll thumb. override fun onItemRangeInserted( positionStart: Int,