Drawables / RecyclerView / Shape Drawable

RecyclerView FastScroll – Part 1

In the previous series we looked at how we could get smoothScrollToPosition() working in a timely manner when using LinearLayoutManager with a large data set. An alternative UX approach to using smoothScrollToPosition() which may be applicable in some use-cases is to avoid smooth scrolling altogether and use the fast scroll behaviour which has long been a staple of ListView. However, RecyclerView does not have fast scroll built in so, in this short series, we’ll look at how to implement fast scroll in RecyclerView.

So the behaviour that we’re after is a normal scroll bar behaviour with a ‘bubble’ which moves along with the RecyclerView scrolling, but when the user drags the bubble, a draggable handle pops up which hides after a short delay when the user ends the drag:

We’ll take the code from the previous series and add fast scroll behaviour to it. The code for this series will be minSdkVersion='21' to simplify the drawables required (and keep the code a little cleaner). It will be easy to port this code to much earlier versions by adapting the drawables accordingly but little or no other changes will be required.

Let’s start by defining the drawable in question. This will be the drag handle which will appear when fast scrolling is active. I have copied the general appearance from the Contacts app fast scroller, its a rectangle with all but the bottom right corner rounded, and we’ll pick up the accent colour:

?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <corners
        android:topLeftRadius="@dimen/fastscroller_handle_corner"
        android:topRightRadius="@dimen/fastscroller_handle_corner"
        android:bottomLeftRadius="@dimen/fastscroller_handle_corner"
        android:bottomRightRadius="0dp" />

    <solid android:color="?android:attr/colorAccent" />

    <size
        android:height="@dimen/fastscroller_handle_size"
        android:width="@dimen/fastscroller_handle_size" />
</shape>

We’re using a shape drawable here, so in Lollipop we’ll automatically get the correct shadow if we apply elevation. The shadow is the main simplification we get by using minSdkVersion='21'. To back port this code you’ll need to use an alternate approach here, such as using a bitmap drawable here instead of a shape drawable.

Our FastScroller control will also provide its own scroll ‘bubble’ – the bubble showing the current scroll position that would normally be displayed with the RecyclerView itself. Once again this is a simple shape drawable which pick up a primary colour from the theme:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <corners android:radius="@dimen/fastscroller_reack_corner" />

    <solid android:color="?android:attr/colorPrimaryDark" />

    <size
        android:height="@dimen/fastscroller_track_height"
        android:width="@dimen/fastscroller_track_width" />
</shape>

This may need a slight tweak of ?android:attr/colorPrimaryDark to get it to work with AppCompat themes.

Next we need to define a layout which will contain the track and handle, with the handle initially invisible:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageButton
        android:id="@+id/fastscroller_handle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@string/fastscroller_handle"
        android:background="@drawable/fastscroller_handle"
        android:elevation="4dp"
        android:visibility="invisible" />

    <ImageView
        android:id="@+id/fastscroller_bubble"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@string/fastscroller_track"
        android:src="@drawable/fastscroller_track"
        android:layout_marginStart="@dimen/fastscroller_track_padding"
        android:layout_marginEnd="@dimen/fastscroller_track_padding" />

</merge>

Next we’ll create a custom view which will encapsulate our fast scroll behaviour:

public class FastScroller extends LinearLayout {
    private View bubble;
    private View handle;


    public FastScroller(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialise(context);
    }

    public FastScroller(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialise(context);
    }

    private void initialise(Context context) {
        setOrientation(HORIZONTAL);
        setClipChildren(false);
        LayoutInflater inflater = LayoutInflater.from(context);
        inflater.inflate(R.layout.fastscroller, this);
        bubble = findViewById(R.id.fastscroller_bubble);
        handle = findViewById(R.id.fastscroller_handle);
    }
    .
    .
    .
}

This is a based upon a horizontal LinearLayout which will provide the fast scroll behaviour for a RecyclerView instance which scrolls vertically (the orientation is hard-coded, but it should be easy enough to adapt this, if necessary). We need to call setClipChildren(false) otherwise our elevation shadows may get clipped. We need to extend LinearLayout rather than, for example FrameLayout, here because the fastscroller.xml layout contains two controls which need to be positioned side-by-side, we want to keep the layout hierarchy as flat as possible so we don’t want an additional layout level in there so we use as the parent of that layout which requires our custom control to extend LinearLayout to correctly position these two Views.

Next we’ll add a couple of methods with will create the animations to show and hide the handle:

public class FastScroller extends LinearLayout {
    .
    .
    .
    private static final int HANDLE_ANIMATION_DURATION = 100;

    private static final String SCALE_X = "scaleX";
    private static final String SCALE_Y = "scaleY";
    private static final String ALPHA = "alpha";

    private AnimatorSet currentAnimator = null;

    private void showHandle() {
        AnimatorSet animatorSet = new AnimatorSet();
        handle.setPivotX(handle.getWidth());
        handle.setPivotY(handle.getHeight());
        handle.setVisibility(VISIBLE);
        Animator growerX = ObjectAnimator.ofFloat(handle, SCALE_X, 0f, 1f).setDuration(HANDLE_ANIMATION_DURATION);
        Animator growerY = ObjectAnimator.ofFloat(handle, SCALE_Y, 0f, 1f).setDuration(HANDLE_ANIMATION_DURATION);
        Animator alpha = ObjectAnimator.ofFloat(handle, ALPHA, 0f, 1f).setDuration(HANDLE_ANIMATION_DURATION);
        animatorSet.playTogether(growerX, growerY, alpha);
        animatorSet.start();
    }

    private void hideHandle() {
        currentAnimator = new AnimatorSet();
        handle.setPivotX(handle.getWidth());
        handle.setPivotY(handle.getHeight());
        Animator shrinkerX = ObjectAnimator.ofFloat(handle, SCALE_X, 1f, 0f).setDuration(HANDLE_ANIMATION_DURATION);
        Animator shrinkerY = ObjectAnimator.ofFloat(handle, SCALE_Y, 1f, 0f).setDuration(HANDLE_ANIMATION_DURATION);
        Animator alpha = ObjectAnimator.ofFloat(handle, ALPHA, 1f, 0f).setDuration(HANDLE_ANIMATION_DURATION);
        currentAnimator.playTogether(shrinkerX, shrinkerY, alpha);
        currentAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                handle.setVisibility(INVISIBLE);
                currentAnimator = null;
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                super.onAnimationCancel(animation);
                handle.setVisibility(INVISIBLE);
                currentAnimator = null;
            }
        });
        currentAnimator.start();
    }
    .
    .
    .
}

These are both pretty straightforward – we just grow and shrink from the bottom right corner while fading in and out.

We also need to know the track length for knowing where to position the bubble, and also determining the item we need to scroll to when we receive a touch event. The easiest way to do this is override onSizeChanged, and get the height whenever it’s called:

public class FastScroller extends LinearLayout {
    .
    .
    .
    private int height;


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        height = h;
    }
    .
    .
    .
}

That’s the basis of our FastScroller, but we now need to handle touch events in order to allow the user to fast scroll, and also track changes in the RecyclerView scrolling so that the bubble position responds to the user directly scrolling the RecyclerView. We’ll look at implementing those in the concluding article in this series.

Normally I like to publish code along with each article, but the above code in isolation doesn’t actually do very much, so in this case I’ll hold it back until the next article when everything will be connected up and working.

© 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.

1 Comment

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.