RecyclerView custom Thumb Size & Length

CoffeeCode
8 min readNov 1, 2020

First things first, imma say: This might not work well with RecyclerViews which have a lot of elements, I couldn’t test it properly yet(I didn’t test many things). And overall, it might not be the prettiest example.
I’m a bit new in Android Development, and posting articles as well, so this will probably be a little messy at start. Also, I know this still has some issues, I’m hoping to motivate myself a bit more with writing this article.

So, to start off, something I worked on required the thumb size from scrollbar to be wider than scrollbar track:

It was a bit weird that android didn’t have a built in field/method/anything to change this, but after a bit of work, I managed to overcome this hurdle by adding transparent ‘pixels’, as a Stroke field.

scrollbar_track.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="8dp"
android:left="8dp"
android:right="8dp"
android:top="8dp">
<shape>
<solid android:color="@color/scroll_bar_color" />
<stroke
android:width="3dp"
android:color="@android:color/transparent" />
<size android:width="4dp"
android:height="4dp" />
</shape>
</item>
</layer-list>

Why layer-list? I couldn’t manage to properly add padding/margins, and this inset was what helped me accomplish this, so it looks nice. You can manipulate the ‘offsets’ with top/right/bottom/left properties in <item>.

You can control the track & thumb width by using the <size android:width> , (android:height -horizontal) for property, in combination with the transparent stroke. As it happens, android:scrollbarSize xml property is also useless(at least visually, didn’t need its functionality).

& The code for thumb_shape.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="8dp"
android:left="8dp"
android:right="8dp"
android:top="8dp">
<shape android:shape="rectangle">
<solid android:color="@color/scroll_bar_color" />
<corners android:radius="4dp" />
</shape>
</item>
</layer-list>

Another answer to a potential question, what about <size> property? Well, as it happens - it’s useless here.

styles.xml

<style name="scrollbar_style">
<item name="android:scrollbarAlwaysDrawVerticalTrack">true</item>
<item name="android:scrollbars">vertical</item>
<item name="android:fadeScrollbars">false</item>
<item name="android:scrollbarThumbVertical">@drawable/thumb_shape</item>
<item name="android:scrollbarTrackVertical">@drawable/scrollbar_track</item>
</style>

You can play with the fades & duration, to tailor it for your needs, but i left it visible here just for debugging. RecyclerView code in xml, just need to add the style.

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
style="@style/scrollbar_style" />

Up to this point, we created it just visually. It looks nice, but another thing I happened to need was thumb length, which wasn’t modifiable by default. So to cut the story short, after hours & hours of trial and error, I ended up creating a custom RecyclerView which implements the ScrollingView.
It’s purpose: Create your own thumb length.

But it didn’t end with that. To make it all work properly, you have to define the track length, and calculate the offset. And yeah, more hours and hours of work later, I came up with the following…

(I wanted to separate this into 2 parts, but i think it’s better to keep it all in a single one, even if a bit large, no? Up to this point as I’ve said, we created this just visually, we will do some modifications on top of that starting from now.)

To start off, inside the values folder, if you don’t already have, create a new file: attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CustomThumbSizeRecyclerView">
<attr name="thumbLength" format="dimension"/>
</declare-styleable>

</resources>

CustomThumbSizeRecyclerView is the name I so conveniently chose for this, quite catchy, right?
It only has a single custom field so far, what I’m hoping to accomplish by writing this is to properly add the field for width(thickness) & color into this, along with several other options, which isn’t going well at the moment, but all in due time.

Rant(ish) mode: off, now to the actual Custom View code, Kotlin version:

class CustomThumbSizeRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr), ScrollingView {

var thumbLength: Int
private var trackLength = -1
private var totalLength = -1

init {
val typedArray =
context.obtainStyledAttributes(attrs, R.styleable.CustomThumbSizeRecyclerView)
thumbLength = dpToPx(
typedArray.getDimension(
R.styleable.CustomThumbSizeRecyclerView_thumbLength,
64F
).toInt()
)
typedArray.recycle()
}
override fun computeHorizontalScrollRange(): Int {
if (trackLength == -1) {
trackLength = this.measuredWidth
}
return trackLength
}

override fun computeHorizontalScrollOffset(): Int {
getWidths()
val highestVisiblePixel = super.computeHorizontalScrollOffset()
return computeScrollOffset(highestVisiblePixel)
}

override fun computeHorizontalScrollExtent(): Int {
return thumbLength
}
override fun computeVerticalScrollRange(): Int {
if (trackLength == -1) {
trackLength = this.measuredHeight
}
return trackLength
}

override fun computeVerticalScrollOffset(): Int {
getHeights()
val highestVisiblePixel = super.computeVerticalScrollOffset()

return computeScrollOffset(highestVisiblePixel)
}

private fun computeScrollOffset(highestVisiblePixel: Int): Int {
val invisiblePartOfRecyclerView: Int = totalLength - trackLength
val scrollAmountRemaining = invisiblePartOfRecyclerView - highestVisiblePixel
return when {
invisiblePartOfRecyclerView == scrollAmountRemaining -> {
0
}
scrollAmountRemaining > 0 -> {
((trackLength - thumbLength) / (invisiblePartOfRecyclerView.toFloat() / highestVisiblePixel)).roundToInt()
}
else -> {
trackLength - thumbLength
}
}
}

override fun computeVerticalScrollExtent(): Int {
return thumbLength
}

private fun getHeights() {
if (totalLength == -1) {
val rec = this.measuredHeight
trackLength = rec
measure(
MeasureSpec.makeMeasureSpec(this.measuredWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
totalLength = this.measuredHeight
measure(
MeasureSpec.makeMeasureSpec(this.measuredWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(rec, MeasureSpec.AT_MOST)
)
}
}
private fun getWidths() {
if (totalLength == -1) {
val rec = this.measuredWidth
trackLength = rec
measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(this.measuredHeight, MeasureSpec.EXACTLY)
)
totalLength = this.measuredWidth
measure(
MeasureSpec.makeMeasureSpec(rec, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(this.measuredHeight, MeasureSpec.EXACTLY)
)
}
}
fun dpToPx(dp: Int): Int {
return (dp * resources.displayMetrics.density).roundToInt()
}
}

Alright, so I just dumped the code on you up there, so I’ll go step by step over it now:

First of all, we created a Custom View element, if you want details about how this works, look for a tutorial about that(In other words, I don’t know how to explain it properly :)). The point is, we made the Custom View extend RecyclerView, and implement ScrollingView interface. Now, the reason that i bolded the ScrollingView now, is just because this part was a pain in the #$$ to work with.

class CustomThumbSizeRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr), ScrollingView {

To properly define Thumb length, and the offset for scrolling, we need to create 3 variables. We initialize thumbLength in the init block, based off of the property we added in attrs.xml, which we will later use to add the Thumb Length attribute field while creating our view, whether that’s in layout or in code. That’s also the reason thumbLength is not private, so we can use it in code.

var thumbLength: Int
private var trackLength = -1
private var totalLength = -1
init {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomThumbSizeRecyclerView)
thumbLength = dpToPx(
typedArray.getDimension(
R.styleable.CustomThumbSizeRecyclerView_thumbLength,
64F
).toInt()
)
typedArray.recycle()
}

Now, I’ll go over vertical calculations, horizontal ones should work just the same(I still didn’t test that part. :) )

computeVerticalScrollRange() calculates the vertical range for the track, in other words: track length, the long thin line on the right side usually, its total length(range). As a side-note, if trackLength isn’t initialized at this point, we initialize it. Though this part might be placed at some other part, didn’t play with that part much(yet).

override fun computeVerticalScrollRange(): Int {
if (trackLength == -1) {
trackLength = this.measuredHeight
}
return trackLength
}

Now onto the next part, computeVerticalScrollExtent(). For those of you which aren’t reading the documentation(Ctrl+Q on windows/linux), this actually refers to thumbLength. Basically, the ‘reason’ I even tried creating this. And aside from adding this, we needed to adapt the computeVerticalScrollOffset so it works with the value of thumbLength that we wanted.

override fun computeVerticalScrollOffset(): Int {
getHeights()
val highestVisiblePixel = super.computeVerticalScrollOffset()

return computeScrollOffset(highestVisiblePixel)
}
override fun computeVerticalScrollOffset(): Int {
getHeights()
val highestVisiblePixel = super.computeVerticalScrollOffset()

return computeScrollOffset(highestVisiblePixel)
}

To compute the scroll offset, first thing we needed was to get the height of the visible RecyclerView, and the invisible part. super.computeVerticalScrollOffset returns the up-most pixel on the screen(VERTICAL RecyclerView), in other words, the ‘highest’ pixel. When we subtract the value of this up-most pixel from the invisible part of the RecyclerView, we will get the remaining amount of scrollable pixels (scrollAmountRemaining).
Based on that, we have 3 scenarios:
#1 When the invisible amount of pixels is equal to scrollAmountRemaining->it means that we are at the top of the RecyclerView.
#2 When the scrollAmountRemaining is above 0->We still have ‘scrollable’ space remaining(RecyclerView didn’t reach the end).
#3 When the scrollAmountRemaining is 0(or less than 0)-> We reached the end of the RecyclerView, no space left for scrolling.

private fun computeScrollOffset(highestVisiblePixel: Int): Int {
val invisiblePartOfRecyclerView: Int = totalLength - trackLength
val scrollAmountRemaining = invisiblePartOfRecyclerView - highestVisiblePixel
return when {
invisiblePartOfRecyclerView == scrollAmountRemaining -> {
0
}
scrollAmountRemaining > 0 -> {
((trackLength - thumbLength) / (invisiblePartOfRecyclerView.toFloat() / highestVisiblePixel)).roundToInt()
}
else -> {
trackLength - thumbLength
}
}
}

To get the heights, first thing we had to do was to manually trigger the (View.)measure function. The way that works is basically(As I’ve understood) that it removes any length constraint this view had. So theoretically this could be infinite, and this is the reason I’m not sure how this works with RecyclerViews that have a lot of elements.
Continuing, we ‘write down’ the height value before we call the measure function, and when the function is done, we write down the unconstrained height into totalLength.
After that, by calling measure, and providing our value before measuring, and MeasureSpec.AT_MOST we restore the RecyclerView back to its original state. And voilà, we got the track length without messing up the views.

private fun getHeights() {
if (totalLength == -1) {
val normalHeight = this.measuredHeight
trackLength = normalHeight
measure(
MeasureSpec.makeMeasureSpec(this.measuredWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
totalLength = this.measuredHeight
measure(
MeasureSpec.makeMeasureSpec(this.measuredWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(normalHeight, MeasureSpec.AT_MOST)
)
}
}

I don’t think I need to mention what dpToPx() does, if you don’t know anything about that, then just google some more.

Next on our agenda, is to add app:thumbLength=”Xdp” into the RecyclerView tag, also we modify it from being a RecyclerView element, into our CustomThumbSizeRecyclerView:

<com.example.something.CustomThumbSizeRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
style="@style/scrollbar_style"
app:thumbLength="20dp" />

An example of how we can instantiate the CustomRecyclerView inside our Activity/Fragment:

val recyclerView  = findViewById<CustomThumbSizeRecyclerView>(R.id.recyclerView)(or)
private lateinit var recyclerView: CustomThumbSizeRecyclerView
(& this inside onCreate(), or some place similar)
recyclerView = findViewById(R.id.recyclerView)

That’s all, folks!

Also I hope this helped someone, the actual code is rather simple(and a bit long with my explanations, I tried to be as non-technical in them as possible, was it too much though?), but I had nothing else to use as a reference, so I did this with lots of trial & error.

I know of several more bugs related to this(such as thumbLength on rotation- if it’s too long, dragging the scrollbar thumb, etc), but time constraints didn’t allow me to finish this, and there isn’t really much interest gathered onto this topic so it won’t be included.

And if you’re actually reading this part, thanks for hanging in there so far, you deserve kudos for that at the very least, and honestly, this scrollbar thumb on my browser is scaring me at this point..

--

--