I have a RecyclerView
with items of varying heights with a scrollbar.
Because of the different heights of the items, the scrollbar changes it's vertical size, dependent on which items are currently displayed (see screenshots).
I have created an example project that displays the problem here.
- Has anyone had the same problem and fixed it?
- How can I override the calculation of the scrollbar height and position to come up with an own implementation?
EDIT: The scrollbar's position and height can be controlled by overriding RecyclerViews
computeVerticalScrollOffset
, computeVerticalScrollRange
and computeVerticalScrollExtent
.
I have no idea though on how to implement these to make the scrollbar work properly with dynamic item heights.
The problem, I reckon, is that RecyclerView
estimates the total height of all items based on the items currently visible and sets position and height of the scrollbar accordingly. One way to solve this might be to give a better estimation of the total height of all items.
The best way to handle this situation may be to somehow calculate the scroll bar range based on the size of each item. That may not be practical or desirable. In lieu of that, here is a simple implementation of a custom RecyclerView that you can play with to try to get what you want. It will show you how you can use the various scroll methods to control the scroll bar. It will stick the size of the thumb to an initial size based upon the number of items displayed. The key thing to remember is that the scroll range is arbitrary but all other measurements (extent, offset) must use the same units.
See the documentation for computeVerticalScrollRange()
.
Here is a video of the result.
Update: The code has been updated to correct a few issues: The movement of the thumb is less jerky and the thumb will now come to rest at the bottom as the RecyclerView
scrolls to the bottom. There are also a few caveats that are given after the code.
MyRecyclerView.java (updated)
public class MyRecyclerView extends RecyclerView {
// The size of the scroll bar thumb in our units.
private int mThumbHeight = UNDEFINED;
// Where the RecyclerView cuts off the views when the RecyclerView is scrolled to top.
// For example, if 1/4 of the view at position 9 is displayed at the bottom of the RecyclerView,
// mTopCutOff will equal 9.25. This value is used to compute the scroll offset.
private float mTopCutoff = UNDEFINED;
public MyRecyclerView(Context context) {
super(context);
}
public MyRecyclerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MyRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
/**
* Retrieves the size of the scroll bar thumb in our arbitrary units.
*
* @return Scroll bar thumb height
*/
@Override
public int computeVerticalScrollExtent() {
return (mThumbHeight == UNDEFINED) ? 0 : mThumbHeight;
}
/**
* Compute the offset of the scroll bar thumb in our scroll bar range.
*
* @return Offset in scroll bar range.
*/
@Override
public int computeVerticalScrollOffset() {
return (mTopCutoff == UNDEFINED) ? 0 : (int) ((getCutoff() - mTopCutoff) * ITEM_HEIGHT);
}
/**
* Computes the scroll bar range. It will simply be the number of items in the adapter
* multiplied by the given item height. The scroll extent size is also computed since it
* will not vary. Note: The RecyclerView must be positioned at the top or this method
* will throw an IllegalStateException.
*
* @return The scroll bar range
*/
@Override
public int computeVerticalScrollRange() {
if (mThumbHeight == UNDEFINED) {
LinearLayoutManager lm = (LinearLayoutManager) getLayoutManager();
int firstCompletePositionw = lm.findFirstCompletelyVisibleItemPosition();
if (firstCompletePositionw != RecyclerView.NO_POSITION) {
if (firstCompletePositionw != 0) {
throw (new IllegalStateException(ERROR_NOT_AT_TOP_OF_RANGE));
} else {
mTopCutoff = getCutoff();
mThumbHeight = (int) (mTopCutoff * ITEM_HEIGHT);
}
}
}
return getAdapter().getItemCount() * ITEM_HEIGHT;
}
/**
* Determine where the RecyclerVIew display cuts off the list of views. The range is
* zero through (getAdapter().getItemCount() - 1) inclusive.
*
* @return The position in the RecyclerView where the displayed views are cut off. If the
* bottom view is partially displayed, this will be a fractional number.
*/
private float getCutoff() {
LinearLayoutManager lm = (LinearLayoutManager) getLayoutManager();
int lastVisibleItemPosition = lm.findLastVisibleItemPosition();
if (lastVisibleItemPosition == RecyclerView.NO_POSITION) {
return 0f;
}
View view = lm.findViewByPosition(lastVisibleItemPosition);
float fractionOfView;
if (view.getBottom() < getHeight()) { // last visible position is fully visible
fractionOfView = 0f;
} else { // last view is cut off and partially displayed
fractionOfView = (float) (getHeight() - view.getTop()) / (float) view.getHeight();
}
return lastVisibleItemPosition + fractionOfView;
}
private static final int ITEM_HEIGHT = 1000; // Arbitrary, make largish for smoother scrolling
private static final int UNDEFINED = -1;
private static final String ERROR_NOT_AT_TOP_OF_RANGE
= "RecyclerView must be positioned at the top of its range.";
}
Caveats
The following issues may need to be addressed depending on the implementation.
The sample code works only for vertical scrolling. The sample code also assumes that the contents of the RecyclerView
are static. Any updates to the data backing the RecyclerView
may cause scrolling issues. If any changes are made that effect the height of any view displayed on the first full screen of the RecyclerView
, the scrolling will be off. Changes below that will probably work OK. This is due to how the code calculates the scrolling offset.
To determine the base value for the scrolling offset, (variable mTopCutOff
), the RecyclerView must be scrolled to the top the first time computeVerticalScrollRange()
is invoked so views can be measured; otherwise, the code will stop with an "IllegalStateException". This is especially troublesome on an orientation change if the RecyclerView
is scrolled at all. A simple way around this would be to inhibit restoration of the scrolling position so it defaults to the top on an orientation change.
(The following is probably not the best solution...)
var lm: LinearLayoutManager = object : LinearLayoutManager(this) {
override fun onRestoreInstanceState(state: Parcelable?) {
// Don't restore
}
}
I hope this helps. (btw, your MCVE made this a lot easier.)
Use item positions as metric of scroll progress. This will cause your scroll indicator to become a bit jumpy, but at least it will remain fixed-sized.
There are multiple implementations of custom scroll indicators for RecyclerView. Most double as fast scrollers.
Here is my own implementation, based on RecyclerViewFastScroller library. Basically, one have to create a custom View subclass, that will be animated, similarly to ScrollView and DrawerLayout:
- Store current offset
- During animation offset position of thumb View via
View#offset*
calls
- During layout set position based on current offset.
You probably don't want to start learning all that magic now, just use some existing fast scrolling library (RecyclerViewFastScroller or one of it's clones).
If I'm not mistaken the attribute android:scollBarSize="Xdp"
should work for you. Add it to your RecyclerView
xml.
That way you decide the size, and it will remain fixed.