Inspired by Cheticamp's solution I managed to spin my own extension of RecyclerView which doesn't have the computeVerticalScrollRange
limitations.
In fact, this alternative solution doesn't require extending computeVerticalScrollRange
at all.
By reasoning with things in terms of spans I managed to think of a solution that doesn't depend on calculating the height of any items in the RecyclerView.
Each item in the list has a span of 1, and I am fixing the scrollbar thumb size to a certain number of spans (meaning the scrollbar doesn't change its height as the user scrolls).
Now consider the following things:
rangeSpanCount
to be the number of spans a.k.a the number of items in the adapter
firstSpan
to be the position of the first visible span (first completely visible if any, otherwise the first partially visible)
lastSpan
to be the position of the last visible span (last completely visible if any, otherwise the last partially visible)
visibleSpanCount
, equal to lastSpan - firstSpan
, to be the number of spans currently visible in the screen
remainingSpanCount
, equal to rangeSpanCount - 1 - visibleSpanCount
, to be the number of spans remaining in the RecyclerView
Then for the sake of the explanation assume we have a list of 9 spans, and only 3 of them can be visible at any given time (although the logic holds even if the number of visible spans at a given moment is dynamic):
0 1 2 3 4 5 6 7 8 9
0 1 2{------------}
| the size of this range is:
{--}2 3 4 |===========> rangeSpanCount - 1 - visibleSpanCount
{-----}3 4 5
{-------}4 5 6
{-----------}6 7 8
{-------------}7 8 9
| you can see that this range is simply computed as:
|===========> firstSpan - 0
Then notice how we can use the range that grows as the scrolling from top to bottom happens and the range of spans that is left out of sight at any given moment to calculate the progress of the scrolling throughout the RecyclerView.
First we figure out how much has the growing range grown:
partialProgress = (firstSpan - 0) / remainingSpanCount
(From 0% all the way to 100% when firstSpan == remainingSpanCount
)
Then we calculate which span among the visible ones better represent the progress of the scrolling throughout the RecyclerView. Basically, we want to make sure the first span (of position 0
) is chosen when RecyclerView is at the very top and the last span (of position rangeSpanCount - 1
) to be chosen when we reach the very bottom. This is important otherwise your scrolling will be off when reaching these edges.
progressSpan = firstSpan + (visibleSpanCount * partialProgress)
And finally, you can use the position of this chosen span and the total number of spans to figure out the actual progress percentage across the RecyclerView, and use the real computed scroll range to determine the best offset for the scrollbar:
scrollProgress = progressSpan / rangeSpanCount
scrollOffset = scrollProgress * super.computeVerticalScrollRange()
And that's it! This solution can be adapted to support the horizontal axis, so it carries none of the caveats from Cheticamp's alternative.
It has one caveat, though: the movement of the scrollbar thumb is discrete, not continuous along the axis, meaning the jumping from one position to the next is noticeable. It is consistent, though, never "shaking" itself / going back and forth while the user performs a scroll to any direction.
This caveat can probably be solved by working with a much higher number of spans in respect to the number of items in the adapter (e.g. having multiple spans per item) but I didn't give it too much thought right now.
I hope my explanation is reasonably clear... and I thank you all for helping me with your answers, it really helped point me to the right direction!
Below you can check out the complete solution and source code:
package cz.nn.calllog.view.utils.recyclerview
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class SmartScrollbarRecyclerView(
context: Context,
attributeSet: AttributeSet?,
defaultStyleAttribute: Int
) : RecyclerView(context, attributeSet, defaultStyleAttribute) {
constructor(
context: Context,
attributeSet: AttributeSet
) : this(context, attributeSet, 0)
constructor(
context: Context
) : this(context, null, 0)
override fun computeVerticalScrollExtent(): Int {
return checkCalculationPrerequisites(
onFailure = {
super.computeVerticalScrollExtent()
},
onSuccess = { _, rangeSpan, scrollRange ->
val extentSpanCount = 1.5F
val scrollExtent = (extentSpanCount / rangeSpan)
(scrollExtent * scrollRange).toInt()
}
)
}
override fun computeVerticalScrollOffset(): Int {
return checkCalculationPrerequisites(
onFailure = {
super.computeVerticalScrollOffset()
},
onSuccess = { layoutManager, rangeSpanCount, scrollRange ->
val firstSpanPosition = calculateFirstVisibleItemPosition(layoutManager)
val lastSpanPosition = calculateLastVisibleItemPosition(layoutManager)
val visibleSpanCount = lastSpanPosition - firstSpanPosition
val remainingSpanCount = rangeSpanCount - 1 - visibleSpanCount
val partialProgress = (firstSpanPosition / remainingSpanCount)
val progressSpanPosition = firstSpanPosition + (visibleSpanCount * partialProgress)
val scrollProgress = progressSpanPosition / rangeSpanCount
(scrollProgress * scrollRange).toInt()
}
)
}
private fun calculateFirstVisibleItemPosition(layoutManager: LinearLayoutManager): Int {
val firstCompletelyVisibleItemPosition = layoutManager.findFirstCompletelyVisibleItemPosition()
return if (firstCompletelyVisibleItemPosition == -1) {
layoutManager.findFirstVisibleItemPosition()
} else {
firstCompletelyVisibleItemPosition
}
}
private fun calculateLastVisibleItemPosition(layoutManager: LinearLayoutManager): Int {
val lastCompletelyVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition()
return if (lastCompletelyVisibleItemPosition == -1) {
layoutManager.findLastVisibleItemPosition()
} else {
lastCompletelyVisibleItemPosition
}
}
private fun checkCalculationPrerequisites(
onFailure: () -> Int,
onSuccess: (LinearLayoutManager, Float, Int) -> Int
): Int {
val layoutManager = layoutManager
if (layoutManager !is LinearLayoutManager) {
return onFailure.invoke()
}
val scrollRange = computeVerticalScrollRange()
if (scrollRange < height) {
return 0
}
val rangeSpanCount = calculateRangeSpanCount()
if (rangeSpanCount == 0F) {
return 0
}
return onSuccess.invoke(layoutManager, rangeSpanCount, scrollRange)
}
private fun calculateRangeSpanCount(): Float {
val recyclerAdapter = adapter ?: return 0F
return recyclerAdapter.itemCount.toFloat()
}
}