Improving accuracy of computeVerticalScrollOffset() For LinearLayoutManager

Bhargav Mogra
3 min readJun 21, 2018

--

What is vertical scroll offset?

What is vertical scroll offset?

The Problem

Recently, I was stuck trying to figure out a way to get the current vertical scroll offset value on my recyclerview. So the first solution that popped into my head was to use the RecyclerView#computeVerticalScrollOffset() API function (which is internally delegated to the attached LayoutManager) , but this function returns skewed values (caused by non-uniform item sizes) , so I try to dig into its implementation to understand why when I scroll through some unusally large items in the recyclerview the offset value returned by the function varies in an unsual manner causing un-desired behavior on the View s that depend on the accuracy of this value. So I find the piece of function that performs the offset calculation ScrollbarHelper#computerScrollOffset which looks like below,

/**
*
@param startChild View closest to start of the list. (top or left)
*
@param endChild View closest to end of the list (bottom or right)
*/
static int computeScrollOffset(RecyclerView.State state, OrientationHelper orientation,
View startChild, View endChild, RecyclerView.LayoutManager lm,
boolean smoothScrollbarEnabled, boolean reverseLayout) {
if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null
|| endChild == null) {
return 0;
}
final int minPosition = Math.min(lm.getPosition(startChild),
lm.getPosition(endChild));
final int maxPosition = Math.max(lm.getPosition(startChild),
lm.getPosition(endChild));
final int itemsBefore = reverseLayout
? Math.max(0, state.getItemCount() - maxPosition - 1)
: Math.max(0, minPosition);
if (!smoothScrollbarEnabled) {
return itemsBefore;
}
final int laidOutArea = Math.abs(orientation.getDecoratedEnd(endChild)
- orientation.getDecoratedStart(startChild));
final int itemRange = Math.abs(lm.getPosition(startChild)
- lm.getPosition(endChild)) + 1;
final float avgSizePerRow = (float) laidOutArea / itemRange;

return Math.round(itemsBefore * avgSizePerRow + (orientation.getStartAfterPadding()
- orientation.getDecoratedStart(startChild)));
}

So we can ignore most of it, the important logic that we should care about here is the itemsBefore * avgSizePerRow bit, the itemsBefore is calculated as

Math.max(0, Math.min(lm.getPosition(startChild), lm.getPosition(endChild))

and avgSizePerRow is calculated as

(Math.abs(orientation.getDecoratedEnd(endChild)
- orientation.getDecoratedStart(startChild))) / (Math.abs(lm.getPosition(startChild)
- lm.getPosition(endChild)) + 1)

which is nothing but the laidOutArea/itemRange

Idea of default implementation

So the idea to calculate the current offset is absolutely brilliant here, the current offset is nothing but the total height of the views that is offscreen (i.e scrollOffset = numberOfItemsScrolledThrough * avgHeightPerItem ), so the problem with the current implementation as far I can see isthat some large items in the current laid out area can skew the accuracy for the total average item height and thus return broken offset values.

So I start thinking about how to improve the accuracy, then on my way back home from work an Idea pops into my head -

Extending the default idea

— instead of using average values of the laid out area why not just get the exact height value for each item in the recyclerview?

So I set out on implementing a custom extension of the LinearLayoutManager, where I override the computerVerticalOffset function, The idea is simple here, since it’s the layout manager it is the one that takes care of laying out all the children of the recylcerview hence in theory it should know the sizes/dimensions of each child, so I decide to keep a Map<Int, Int> of the child view’s adapterPosition to its calculated height and trivial solution to the problem is then possible

class LinearLayoutManagerAccurateOffset(context: Context?) : LinearLayoutManager(context) {

// map of child adapter position to its height.
private val childSizesMap = mutableMapOf<Int, Int>()

override fun onLayoutCompleted(state: RecyclerView.State?) {
super.onLayoutCompleted(state)
for (i in 0 until childCount) {
val child = getChildAt(i)
childSizesMap[getPosition(child)] = child.height
}
}

override fun computeVerticalScrollOffset(state: RecyclerView.State?): Int {
if (childCount == 0) {
return 0
}
val firstChild = getChildAt(0)
val firstChildPosition = getPosition(firstChild)
var scrolledY: Int = -firstChild.y.toInt()
for (i in 0 until firstChildPosition) {
scrolledY += childSizesMap[i] ?: 0
}
return scrolledY
}

}

So after each onLayoutCompleted I update the Map with the heights of the children that were just laid out, and everytime I computerVerticalScrollOffset I add up all the heights of the children upto the current firstVisible child’s adapter position + the y offset the firstVisibleChild this way now I have the accurate verticalScrollOffset for my recyclerview, the solution time complexity wise is very trivial.

Credits:

This thread helped me a lot in identifying the problem with the current implementation, and yigit’s comment also gave me the idea to extend the LinearLayoutManager

--

--