Custom Controls / RecyclerView

RecyclerView FastScroll – Part 2

In the previous article we got our FastScroller control framework in place. In this concluding article in this series we’ll add touch and scrolling behaviours.

The first thing that we need is an internal method which will be called in order to set the positions of both the bubble and handle whenever the scroll position changes either because of a touch event within FastScroller, or because the user scrolls the RecyclerView itself:

public class FastScroller extends LinearLayout {
    .
    .
    .
    private void setPosition(float y) {
        float position = y / height;
        int bubbleHeight = bubble.getHeight();
        bubble.setY(getValueInRange(0, height - bubbleHeight, (int) ((height - bubbleHeight) * position)));
        int handleHeight = handle.getHeight();
        handle.setY(getValueInRange(0, height - handleHeight, (int) ((height - handleHeight) * position)));
    }

    private int getValueInRange(int min, int max, int value) {
        int minimum = Math.max(min, value);
        return Math.min(minimum, max);
    }
    .
    .
    .
}

Theres a little bit of maths required here as the handle and bubble may be different heights and we need to handle each independently. When scrolling we want each have its top edge at the top when item 0 in the list is visible, and its bottom edge at the bottom when the final item in the list is visible.

getValueInRange() is a utility method which ensure that the bubble and handle always remain within their track.

Our FastScroller control is associated with a RecyclerView, and so the next task is provide a mechanism to make that association using a simple setter:

public class FastScroller extends LinearLayout {
    .
    .
    .
    private final ScrollListener scrollListener = new ScrollListener();

    public void setRecyclerView(RecyclerView recyclerView) {
        this.recyclerView = recyclerView;
        recyclerView.setOnScrollListener(scrollListener);
    }

    private class ScrollListener extends OnScrollListener {
        @Override
        public void onScrolled(RecyclerView rv, int dx, int dy) {
            View firstVisibleView = recyclerView.getChildAt(0);
            int firstVisiblePosition = recyclerView.getChildPosition(firstVisibleView);
            int visibleRange = recyclerView.getChildCount();
            int lastVisiblePosition = firstVisiblePosition + visibleRange;
            int itemCount = recyclerView.getAdapter().getItemCount();
            int position;
            if (firstVisiblePosition == 0) {
                position = 0;
            } else if (lastVisiblePosition == itemCount - 1) {
                position = itemCount - 1;
            } else {
                position = firstVisiblePosition;
            }
            float proportion = (float) position / (float) itemCount;
            setPosition(height * proportion);
        }
    }
}

When the setter is called, is sets an OnScrollListener instance which gets called whenever the user directly scrolls the RecyclerView so that we can adjust the positions of the handle and bubble accordingly. There’s a little bit of logic required here to provide the correct positioning at the top and bottom of the list

The next thing we need to look at in our FastScroller control is handling touch events. The behaviour that we’re looking to implement is: When the user taps within the control, the handle will appear. The user can drag up and down to change the current position. When the user releases, then there will be a short delay before the handle is hidden again. This is implemented by overriding onTouchEvent():

public class FastScroller extends LinearLayout {
    .
    .
    .
    private static final int HANDLE_HIDE_DELAY = 1000;
    private static final int TRACK_SNAP_RANGE = 5;

    private final HandleHider handleHider = new HandleHider();

    @Override
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN || event.getAction() == MotionEvent.ACTION_MOVE) {
            setPosition(event.getY());
            if (currentAnimator != null) {
                currentAnimator.cancel();
            }
            getHandler().removeCallbacks(handleHider);
            if (handle.getVisibility() == INVISIBLE) {
                showHandle();
            }
            setRecyclerViewPosition(event.getY());
            return true;
        } else if (event.getAction() == MotionEvent.ACTION_UP) {
            getHandler().postDelayed(handleHider, HANDLE_HIDE_DELAY);
            return true;
        }
        return super.onTouchEvent(event);
    }

    private class HandleHider implements Runnable {
        @Override
        public void run() {
            hideHandle();
        }
    }

    private void setRecyclerViewPosition(float y) {
        if (recyclerView != null) {
            int itemCount = recyclerView.getAdapter().getItemCount();
            float proportion;
            if (bubble.getY() == 0) {
                proportion = 0f;
            } else if (bubble.getY() + bubble.getHeight() >= height - TRACK_SNAP_RANGE) {
                proportion = 1f;
            } else {
                proportion = y / (float) height;
            }
            int targetPos = getValueInRange(0, itemCount - 1, (int) (proportion * (float) itemCount));
            recyclerView.scrollToPosition(targetPos);
        }
    }
    .
    .
    .
}

When we receive a down or move action we set the current position to match the current Y position, cancel any animations which may be running, and any delayed handler callbacks (more on this in a second). If the handle is not visible then we call the method we created earlier to show it. Finally we set the current position of the RecyclerView before retuning true to consume the touch event.

When we receive an up action we use a Handler to post a delayed action to hide the handle after a short delay.

When we set the RecyclerView position we include a bit of logic to snap to the bottom if we’re within a certain distance of the bottom, or snap to the top if the first item is visible, otherwise we calculate the correct proportion value if we’re somewhere in the middle.

Note that we’re only using scrollToPosition() here and not smoothScrollToPosition() so we’ll have none of the issues covered in the previous series.

That’s our control complete. All that’s left is to connect it in. First we add it to the layout containing our RecyclerView:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingLeft="@dimen/activity_horizontal_margin"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:scrollbars="none"
        tools:context=".MainActivity" />

    <com.stylingandroid.smoothscrolling.FastScroller
        android:id="@+id/fast_scroller"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_alignParentEnd="true" />
</RelativeLayout>

Finally we need to create the association between RecyclerView and FastScroller:

public class MainActivity extends Activity {
    .
    .
    .
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        recyclerView = (RecyclerView) findViewById(R.id.recyclerview);
        recyclerView.setAdapter(LargeAdapter.newInstance(this));
        int duration = getResources().getInteger(R.integer.scroll_duration);
        recyclerView.setLayoutManager(new ScrollingLinearLayoutManager(this, LinearLayoutManager.VERTICAL, false, duration));
        FastScroller fastScroller = (FastScroller) findViewById(R.id.fastscroller);
        fastScroller.setRecyclerView(recyclerView);
    }
    .
    .
    .
}

That’s it. We can now see our fas scrolling behaviour:

One final note: In the Contacts app the FastScroller handle contains a letter indicating the current position within the list. This is using a slightly more complex Adapter than our example, but it should not be a massive amount of work to add this. Perhaps this is something that we’ll cover in a future post.

The source code for this series is available here.

© 2015, Mark Allison. All rights reserved.

Copyright © 2015 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

10 Comments

  1. I think the website to download the source code isn’t updated. It lacks the code you have here, and the last comment there is of “part 1” .
    Please update it, and also please show how to fix the location of the label-popup (the bubble) that is shown while you drag the scroller.

      1. There *is* no way it looks on Android 5.1 – it is not a control built in to the system, it is implemented differently by different apps.

        1. I meant like how the listView’s fast-scroller looks like.
          I know that RecyclerView isn’t built in…

  2. how to set text for fastscroller. when drag scroll, it appen corresponding letters item of recycleview curents ???

  3. Sorry for my English, but o realy need help.
    I have next bag: when i move the fastscroller – he is twitches, twinkles. How it fix?

  4. Two doubts about all this:

    1) When I have few elements on my list (let’s say 50) and scroll recyclerview itself, the animation is not so fluid. Any Idea on how to make it better?

    2) When I change the number of elements on the list to 50 and scroll the list till the end, the bubble does not reach the end. Any idea why?

  5. Awesome tutorial. I was wondering if you were still thinking or planning to add the text to the bubble?

    Thanks, JP

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.