Created
May 11, 2015 21:00
-
-
Save Dagothig/8a695f240c582eb20d59 to your computer and use it in GitHub Desktop.
Fast scroller for Android recyclerView based with adjustments for sticky headers
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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