How to make scrollbars from scratch
I’ve been working on making a scroll view for libvaxis and calculating scrollbar dimensions in a performant way is hard. Or at least doing it in a general way when you don’t necessarily know the precise dimensions of the view makes it hard.
I couldn’t find any guides online for making your own scrollbar. Everything that came up was web related; CSS tricks for custom scrollbars, JavaScript implementations of scrollbars, etc. So I figured I might as well write this up and maybe someone will find this useful. And with some luck someone can tell me the actual best way to do this :)
Glossary
Let’s start by defining some terms:
- Scrollbar: The whole thing you interact with.
- Scroll thumb: The part of the UI that moves around to indicate the current position in a view. This fades out in most modern implementations of scrollbars.
- Scroll track: The part of the UI underneath the thumb. Usually used to indicate where the scrollbar is, at least when it’s visible. Modern scrollbars don’t typically show this.
What do we need to know to make a scrollbar?
In theory, making a scrollbar is easy. You really just need to know 3 things and do some math from there:
- The length of the content you’re scrolling through.
- The length of the space you have to show content.
- The distance you’ve scrolled so far.
If, for whatever reason, the space you have to show content is not the same size as the space you have for the scrollbar you need some more math, but not any more information.
Calculating sizes
Once you have these 3 things you just need to do calculations. Let’s call the content length
l_c
(for content length), the length of the space you have to show content
l_v
(for view length), and the distance you’ve scrolled so far
d
. What we need to calculate is then:
- The length of the scrollbar thumb,
l_t
; and - the distance the thumb has moved down the scrollbar,
d_t
.
Both are proportional to the values we already know. The length of the scrollbar thumb in the scrollbar is proportional to the length of the view, and the distance the thumb has moved is proportional to the distance you’ve scrolled in the view.
l_t = l_v * ( l_v / l_c )
d_t = d * ( l_v / l_c )
So far so good, but the math is the easy part. The hard part is actually getting the lengths needed to do the math.
The big challenge: getting the values you need for the math
The biggest challenge is getting the correct height for the content. Generally speaking you need to draw the content to know the height it will occupy on the screen. Let’s assume for a moment that the content in the scrollview is massive. Let’s say something as big as a million elements. If you want your scrollview to be performant you don’t actually want to draw all of that every time. Instead, you only want to draw what’s going to be visible in the scroll view. That way you’re not wasting time drawing something that won’t be visible.
But this comes with a problem: now you don’t know the true height of the whole content, so you can’t get an accurate value for
l_c
(content height). And without an accurate
l_c
all of your other numbers will be wrong too, so your scrollbar will be slightly off at best, completely wrong at worst.
I couldn’t find a well explained solution to this problem online. I could’ve spent days digging through open-source projects, but the ones I looked at use a completely different paradigm when it comes to rendering than
libvaxis
does, and I didn’t feel like trying to learn a new codebase just to figure out if I could even use that approach. So I came up with a few solutions.
Solution 1: Measure the full height
This is the naïve approach, and it’s expensive. If you draw everything and measure the height you’ll have the most accurate
l_c
possible. You’ve gone through drawing things that aren’t visible, which is absolutely a waste of time, but you can draw the scrollbar properly now.
You can do things to mitigate the time you take for this. For example, you can store
l_c
between draws and only update it when the elements in the scroll view change. This will probably cause lag when a user is changing the elements though, because every time the children change you now need to redraw every single element.
Another way to approach this would be to do this calculation in a different thread so it happens in the background while other things are happening. This would remove the lag, but it adds a whole other layer of complexity that you may not want to manage.
I’d consider this an appropriate approach if you’re drawing content that doesn’t change or drawing relatively few items that are inexpensive to draw. "Relatively few" here depends on many factors, such as the elements you’re drawing, the platform you’re working on, and the hardware you’re working with, just like any other performance problem.
Solution 2: Estimate full height based on what’s been drawn
Counting the child elements should, in theory, be relatively inexpensive. That depends on the implementation, of course, but typically either you as the library, or whoever is using the scroll view, will know how many elements are in the scroll view.
Since we know how many elements there are in total, we can count the elements drawn in the currently visible part of the view, and use the average height of those elements to estimate how big the height of all the elements combined is.
If all the elements in the scroll view have roughly the same height and are evenly distributed, this should work pretty well. But the greater the variance in height is, the worse this will look. The scroll thumb will get jumpy, and may even start jumping back and forth as you scroll in a single direction. It’s not great.
I’d consider this an appropriate approach if you can be sure the elements have the same height — or something close enough to it. If that’s the case it doesn’t matter if the average height is a little off since it won’t be noticable.
Solution 3: Use proxy values to represent the heights
Instead of trying to work with the heights directly, it’s also possible to map the scroll thumb onto the scrollbar by using the indexes into the child element list to represent the heights and positions. So, instead of tracking the height of the current view, instead you track how many elements were drawn. And instead of tracking the distance you’ve scrolled down the view, you instead track how many elements you’ve scrolled past.
This effectively uses the indexes you’re working with as proxies you use to map onto actual distances in the scrollbar.
This does have the downside of possibly giving you an inaccurate size of the scrollbar thumb, and it can change while scrolling, if the number of elements on screen changes between draws, i.e. the height of the elements is inconsistent.
You could work around the jumpiness of the scroll thumb by doing something like keeping a running average of the number of elements on the screen to try to even out the estimated height. Generally speaking I think this is a strictly worse option than Solution 2, unless there’s something specific about your situation that makes this a good proxy.
In the case of
libvaxis
the number of elements happens to be a good proxy because we’re rendering things in a terminal, so the number of elements maps pretty well onto rows of text rendered there.
Solution 4: Cheat
Well, not literally.
The way I ended up doing this was to use Solution 3 and estimate the height of the content based on what’s being rendered, using the number of elements drawn and the total number of elements as proxies for the content height.
The cheating that we do is to ask whoever is using the library to provide an explicit height for all of the content. Since whoever is using the library will probably know the height of the content anyway, they might as well include it!
So you get the best of both worlds. If you don’t want to bother calculating the total height yourself — or can’t calculate it for some reason — the estimate is good enough for most cases, and pretty performant. However, if you do already know the total height we can skip the measurements and go straight to the math, which is very performant!
It’s not a perfect API, and far from seamless. But it works pretty well, and I’m quite happy with the solution as it is.