I ended up coming up with something slightly different than the above. It's not ideal, but it's working acceptably well for me, and may be helpful to someone else. I won't accept this answer in the hopes that someone else comes along with something better and less hacky (and it's possible that I'm misunderstanding the RecyclerView
implementation and missing some simple way of doing this, but in the meantime, this is good enough for government work!)
The basics of the implementation are these: The scrolling in a RecyclerView
is sort of split up between the RecyclerView
and the LinearLayoutManager
. There are two cases that I need to handle:
- The user flings the view. The default behavior is that the
RecyclerView
passes the fling to an internal Scroller
which then performs the scrolling magic. This is problematic because then the RecyclerView
usually settles in an unsnapped position. I solve this by overriding the RecyclerView
fling()
implementation and instead of flinging, smoothscroll the LinearLayoutManager
to a position.
- The user lifts their finger with insufficient velocity to initiate a scroll. No fling occurs in this case. I want to detect this case in the event that the view is not in a snapped position. I do this by overriding the
onTouchEvent
method.
The SnappyRecyclerView
:
public final class SnappyRecyclerView extends RecyclerView {
public SnappyRecyclerView(Context context) {
super(context);
}
public SnappyRecyclerView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SnappyRecyclerView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
public boolean fling(int velocityX, int velocityY) {
final LayoutManager lm = getLayoutManager();
if (lm instanceof ISnappyLayoutManager) {
super.smoothScrollToPosition(((ISnappyLayoutManager) getLayoutManager())
.getPositionForVelocity(velocityX, velocityY));
return true;
}
return super.fling(velocityX, velocityY);
}
@Override
public boolean onTouchEvent(MotionEvent e) {
// We want the parent to handle all touch events--there's a lot going on there,
// and there is no reason to overwrite that functionality--bad things will happen.
final boolean ret = super.onTouchEvent(e);
final LayoutManager lm = getLayoutManager();
if (lm instanceof ISnappyLayoutManager
&& (e.getAction() == MotionEvent.ACTION_UP ||
e.getAction() == MotionEvent.ACTION_CANCEL)
&& getScrollState() == SCROLL_STATE_IDLE) {
// The layout manager is a SnappyLayoutManager, which means that the
// children should be snapped to a grid at the end of a drag or
// fling. The motion event is either a user lifting their finger or
// the cancellation of a motion events, so this is the time to take
// over the scrolling to perform our own functionality.
// Finally, the scroll state is idle--meaning that the resultant
// velocity after the user's gesture was below the threshold, and
// no fling was performed, so the view may be in an unaligned state
// and will not be flung to a proper state.
smoothScrollToPosition(((ISnappyLayoutManager) lm).getFixScrollPos());
}
return ret;
}
}
An interface for snappy layout managers:
/**
* An interface that LayoutManagers that should snap to grid should implement.
*/
public interface ISnappyLayoutManager {
/**
* @param velocityX
* @param velocityY
* @return the resultant position from a fling of the given velocity.
*/
int getPositionForVelocity(int velocityX, int velocityY);
/**
* @return the position this list must scroll to to fix a state where the
* views are not snapped to grid.
*/
int getFixScrollPos();
}
And here is an example of a LayoutManager
that subclasses the LinearLayoutManager
to result in a LayoutManager
with smooth scrolling:
public class SnappyLinearLayoutManager extends LinearLayoutManager implements ISnappyLayoutManager {
// These variables are from android.widget.Scroller, which is used, via ScrollerCompat, by
// Recycler View. The scrolling distance calculation logic originates from the same place. Want
// to use their variables so as to approximate the look of normal Android scrolling.
// Find the Scroller fling implementation in android.widget.Scroller.fling().
private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1)
private static float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9));
private static double FRICTION = 0.84;
private double deceleration;
public SnappyLinearLayoutManager(Context context) {
super(context);
calculateDeceleration(context);
}
public SnappyLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
super(context, orientation, reverseLayout);
calculateDeceleration(context);
}
private void calculateDeceleration(Context context) {
deceleration = SensorManager.GRAVITY_EARTH // g (m/s^2)
* 39.3700787 // inches per meter
// pixels per inch. 160 is the "default" dpi, i.e. one dip is one pixel on a 160 dpi
// screen
* context.getResources().getDisplayMetrics().density * 160.0f * FRICTION;
}
@Override
public int getPositionForVelocity(int velocityX, int velocityY) {
if (getChildCount() == 0) {
return 0;
}
if (getOrientation() == HORIZONTAL) {
return calcPosForVelocity(velocityX, getChildAt(0).getLeft(), getChildAt(0).getWidth(),
getPosition(getChildAt(0)));
} else {
return calcPosForVelocity(velocityY, getChildAt(0).getTop(), getChildAt(0).getHeight(),
getPosition(getChildAt(0)));
}
}
private int calcPosForVelocity(int velocity, int scrollPos, int childSize, int currPos) {
final double dist = getSplineFlingDistance(velocity);
final double tempScroll = scrollPos + (velocity > 0 ? dist : -dist);
if (velocity < 0) {
// Not sure if I need to lower bound this here.
return (int) Math.max(currPos + tempScroll / childSize, 0);
} else {
return (int) (currPos + (tempScroll / childSize) + 1);
}
}
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, State state, int position) {
final LinearSmoothScroller linearSmoothScroller =
new LinearSmoothScroller(recyclerView.getContext()) {
// I want a behavior where the scrolling always snaps to the beginning of
// the list. Snapping to end is also trivial given the default implementation.
// If you need a different behavior, you may need to override more
// of the LinearSmoothScrolling methods.
protected int getHorizontalSnapPreference() {
return SNAP_TO_START;
}
protected int getVerticalSnapPreference() {
return SNAP_TO_START;
}
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
return SnappyLinearLayoutManager.this
.computeScrollVectorForPosition(targetPosition);
}
};
linearSmoothScroller.setTargetPosition(position);
startSmoothScroll(linearSmoothScroller);
}
private double getSplineFlingDistance(double velocity) {
final double l = getSplineDeceleration(velocity);
final double decelMinusOne = DECELERATION_RATE - 1.0;
return ViewConfiguration.getScrollFriction() * deceleration
* Math.exp(DECELERATION_RATE / decelMinusOne * l);
}
private double getSplineDeceleration(double velocity) {
return Math.log(INFLEXION * Math.abs(velocity)
/ (ViewConfiguration.getScrollFriction() * deceleration));
}
/**
* This implementation obviously doesn't take into account the direction of the
* that preceded it, but there is no easy way to get that information without more
* hacking than I was willing to put into it.
*/
@Override
public int getFixScrollPos() {
if (this.getChildCount() == 0) {
return 0;
}
final View child = getChildAt(0);
final int childPos = getPosition(child);
if (getOrientation() == HORIZONTAL
&& Math.abs(child.getLeft()) > child.getMeasuredWidth() / 2) {
// Scrolled first view more than halfway offscreen
return childPos + 1;
} else if (getOrientation() == VERTICAL
&& Math.abs(child.getTop()) > child.getMeasuredWidth() / 2) {
// Scrolled first view more than halfway offscreen
return childPos + 1;
}
return childPos;
}
}