Skip to content

Instantly share code, notes, and snippets.

@Dagothig
Created May 11, 2015 21:00
Show Gist options
  • Save Dagothig/8a695f240c582eb20d59 to your computer and use it in GitHub Desktop.
Save Dagothig/8a695f240c582eb20d59 to your computer and use it in GitHub Desktop.
Fast scroller for Android recyclerView based with adjustments for sticky headers
/**
* FastScroller for RecyclerView with adjustments for sticky headers
* based off the POC from https://github.com/AndroidDeveloperLB/LollipopContactsRecyclerViewFastScroller
* the adjustments specifically target the SLiM library (https://github.com/TonicArtos/SuperSLiM)
**/
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.nineoldandroids.animation.Animator;
import com.nineoldandroids.animation.AnimatorListenerAdapter;
import com.nineoldandroids.animation.ObjectAnimator;
import com.nineoldandroids.view.ViewHelper;
import static android.support.v7.widget.RecyclerView.NO_POSITION;
import static android.support.v7.widget.RecyclerView.OnScrollListener;
public class FastScroller extends LinearLayout {
protected static final int BUBBLE_ANIMATION_DURATION = 100;
// This to avoid the miscalculations caused by the empty header view at the start of the adapter (it never gets inflated for some reason?)
public static final int IGNORED_AMOUNT = 1;
protected TextView bubble;
protected View handle;
protected RecyclerView recyclerView;
protected final ScrollListener scrollListener = new ScrollListener();
protected int height;
protected int handleLeeway;
protected ObjectAnimator currentAnimator = null;
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public FastScroller(final Context context, final AttributeSet attrs, final int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialise(context);
}
public FastScroller(final Context context) {
super(context);
initialise(context);
}
public FastScroller(final Context context, final AttributeSet attrs) {
super(context, attrs);
initialise(context);
}
private void initialise(Context context) {
setOrientation(HORIZONTAL);
setClipChildren(false);
LayoutInflater inflater = LayoutInflater.from(context);
inflater.inflate(R.layout.recycler_view_fast_scroller__fast_scroller, this, true);
bubble = (TextView) findViewById(R.id.fast_scroller_bubble);
handle = findViewById(R.id.fast_scroller_handle);
bubble.setVisibility(INVISIBLE);
handleLeeway = getResources().getDimensionPixelSize(R.dimen.base_margin) * 2;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
height = h;
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
final int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (event.getX() < ViewHelper.getX(handle) - handleLeeway)
return false;
if (currentAnimator != null)
currentAnimator.cancel();
if (bubble.getVisibility() == INVISIBLE)
showBubble();
handle.setSelected(true);
case MotionEvent.ACTION_MOVE:
final float y = event.getY();
setRecyclerViewPosition(y / (1 - event.getSize() / 2));
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
handle.setSelected(false);
hideBubble();
return true;
}
return super.onTouchEvent(event);
}
public void setRecyclerView(RecyclerView recyclerView) {
this.recyclerView = recyclerView;
recyclerView.addOnScrollListener(scrollListener);
}
private void setRecyclerViewPosition(float y) {
if (recyclerView != null) {
BubbleTextGetter bubbleTextGetter = ((BubbleTextGetter) recyclerView.getAdapter());
float displayedCount = recyclerView.getHeight() / bubbleTextGetter.getAverageCellSize();
int itemCount = recyclerView.getAdapter().getItemCount() - IGNORED_AMOUNT;
float proportion = Math.max(0, Math.min(1, y / (float) height));
int targetPos = getValueInRange(0, itemCount - 1, (int) (proportion * (itemCount - displayedCount))) + IGNORED_AMOUNT;
recyclerView.scrollToPosition(targetPos);
int bubblePos = getValueInRange(0, itemCount - 1, (int) (proportion * (float) itemCount)) + IGNORED_AMOUNT;
String bubbleText = bubbleTextGetter.getTextToShowInBubble(bubblePos);
bubble.setText(bubbleText);
updatePosition();
}
}
private int getValueInRange(int min, int max, int value) {
int minimum = Math.max(min, value);
return Math.min(minimum, max);
}
private void setBubbleAndHandlePosition(float proportion) {
int bubbleHeight = bubble.getHeight();
int handleHeight = handle.getHeight();
float y = proportion * (height - handleHeight);
ViewHelper.setY(handle, y);
ViewHelper.setY(bubble, getValueInRange(0, height - bubbleHeight, (int) (y - bubbleHeight + handleHeight / 2)));
}
private void showBubble() {
bubble.setVisibility(VISIBLE);
if (currentAnimator != null)
currentAnimator.cancel();
currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 0f, 1f).setDuration(BUBBLE_ANIMATION_DURATION);
currentAnimator.start();
}
private void hideBubble() {
if (currentAnimator != null)
currentAnimator.cancel();
currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 1f, 0f).setDuration(BUBBLE_ANIMATION_DURATION);
currentAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
bubble.setVisibility(INVISIBLE);
currentAnimator = null;
}
@Override
public void onAnimationCancel(Animator animation) {
super.onAnimationCancel(animation);
bubble.setVisibility(INVISIBLE);
currentAnimator = null;
}
});
currentAnimator.start();
}
protected void updatePosition() {
float proportion;
propCalc:
{
Float firstTop = null, firstHeight = null;
Float lastTop = null, lastHeight = null;
float firstPos = NO_POSITION;
float lastPos = NO_POSITION;
{
View firstView = null;
View lastView = null;
for (int i = 0, n = recyclerView.getChildCount(); i < n; i++) {
View view = recyclerView.getChildAt(i);
int position = recyclerView.getChildAdapterPosition(view);
if (firstPos == NO_POSITION) {
// If the view has a top greater than 0, then there is currently a sticky header at the top, and the top needs to be offset by it
if (view.getTop() > 0) {
// Find the header view
for (int si = 0, sn = recyclerView.getChildCount(); si < sn; si++) {
View headerView = recyclerView.getChildAt(si);
int headerPos = recyclerView.getChildAdapterPosition(headerView);
if (headerPos == position - 1) {
firstPos = headerPos;
firstView = headerView;
firstHeight = (float)headerView.getHeight();
firstTop = view.getTop() - firstHeight;
break;
}
}
}
if (firstPos == NO_POSITION) {
firstPos = position;
firstView = view;
firstHeight = (float)view.getHeight();
firstTop = (float)firstView.getTop();
}
}
if (lastPos == NO_POSITION || lastPos < position) {
lastPos = position;
lastView = view;
lastHeight = (float)lastView.getHeight();
lastTop = (float)lastView.getTop();
}
}
if (firstView == null || lastView == null) {
proportion = 0;
break propCalc;
}
}
// Sometimes (once in a blue moon really) the empty top is actually seen, so it does go to 0 for the pos from time to time (rarely)
if (firstPos >= IGNORED_AMOUNT) {
firstPos -= IGNORED_AMOUNT;
lastPos -= IGNORED_AMOUNT;
}
float firstViewTopProp = -(firstTop / firstHeight);
if (Float.isNaN(firstViewTopProp)) firstViewTopProp = 0;
float lastViewTopProp = ((height - lastTop) / lastHeight);
if (Float.isNaN(lastViewTopProp)) lastViewTopProp = 0;
firstPos += firstViewTopProp;
lastPos += lastViewTopProp;
float visibleRange = lastPos - firstPos;
int itemCount = recyclerView.getAdapter().getItemCount() - IGNORED_AMOUNT;
float firstViewWeight = (itemCount - visibleRange - firstPos) / (itemCount - visibleRange);
float lastViewWeight = (lastPos - visibleRange) / (itemCount - visibleRange);
proportion = ((1 - firstViewWeight) + lastViewWeight) / 2;
}
setBubbleAndHandlePosition(proportion);
}
private class ScrollListener extends OnScrollListener {
@Override public void onScrolled(RecyclerView rv, int dx, int dy) { updatePosition(); }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment