74

I am trying to use the new RecyclerView class for a scenario where I want the component to snap to a specific element when scrolling (The old Android Gallery comes to mind as an example of such a list with a center-locked item).

This is the approach that I am taking thus far:

I have an interface, ISnappyLayoutManager, which contains a method, getPositionForVelocity, which calculates at which position the view should end the scrolling given the initial fling velocity.

public interface ISnappyLayoutManager {
    int getPositionForVelocity(int velocityX, int velocityY);  
}

Then I have a class, SnappyRecyclerView, which subclasses RecyclerView and overrides its fling() method in such a manner as to fling the view the exact right amount:

public final class SnappyRecyclerView extends RecyclerView {

    /** other methods deleted **/

    @Override
    public boolean fling(int velocityX, int velocityY) {
        LayoutManager lm = getLayoutManager();

        if (lm instanceof ISnappyLayoutManager) {
            super.smoothScrollToPosition(((ISnappyLayoutManager) getLayoutManager())
                    .getPositionForVelocity(velocityX, velocityY));
        }
        return true;
    }
}

I am not very happy with this approach for several reasons. First of all, it seems counter to the philosophy of the 'RecyclerView' to have to subclass it to implement a certain type of scrolling. Second, if I want to just use the default LinearLayoutManager, this becomes somewhat complex as I have to mess around with its internals in order to understand its current scroll state and calculate out exactly where this scrolls to. Finally, this doesn't even take care of all the possible scroll scenarios, as if you move the list and then pause and then lift a finger, no fling event occurs (the velocity is too low) and so the list remains in a halfway position. This can possibly be taken care of by adding an on scroll state listener to the RecyclerView, but that also feels very hacky.

I feel like I must be missing something. Is there a better way to do this?

Vadim Kotov
  • 8,084
  • 8
  • 48
  • 62
Catherine
  • 13,588
  • 9
  • 39
  • 60

13 Answers13

82

With LinearSnapHelper, this is now very easy.

All you need to do is this:

SnapHelper helper = new LinearSnapHelper();
helper.attachToRecyclerView(recyclerView);

It's that simple! Note that LinearSnapHelper was added in the Support Library starting from version 24.2.0.

Meaning you have to add this to your app module's build.gradle

compile "com.android.support:recyclerview-v7:24.2.0"

Edit: AndroidX LinearSnapHelper

Arka Prava Basu
  • 2,366
  • 3
  • 18
  • 34
razzledazzle
  • 6,900
  • 4
  • 26
  • 37
  • 5
    unfortunately it snaps to the middle of the list item – JPLauber Nov 04 '16 at 13:31
  • 10
    Worth to note, in case anyone has the same issues with this solution then I did: if you get "IllegalStateException: An instance of OnFlingListener already set" when setting up the recyclerview, you should call recyclerView.setOnFlingListener(null); before snapHelper.attachToRecyclerView(recyclerView); – Analizer Dec 06 '16 at 13:26
  • How can I control the speed of the snap with the SnapHelper? – Tyler Pfaff Aug 13 '17 at 18:59
  • 3
    @sativa "The implementation will snap the center of the target child view to the center of the attached RecyclerView. If you intend to change this behavior then override calculateDistanceToFinalSnap(RecyclerView.LayoutManager, View)." – Jake Jan 30 '18 at 21:08
  • How to snap programmatically, cuz it doesnt get snapped until we tap or scroll a bit, any workaround folks? – NotABot Feb 01 '18 at 13:19
61

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:

  1. 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.
  2. 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;
    }

}
Catherine
  • 13,588
  • 9
  • 39
  • 60
  • 1
    Great answer! Also a good starting point for other custom scroll behavior where you want to also customize the user fling/scroll end position. Only thing was that the `Constant.INCHES_PER_METER` doesn't exists so I set it to `39.3700787` myself. – Mac_Cain13 Nov 21 '14 at 06:49
  • **+1** Actually this is pretty good. It can be easily extended to support static GridLayouts by replacing the `+ 1`s with `+ getSpanCount()`. It requires a bit more work to calculate this for dynamic grid layouts with different cell sizes all along. Thanks @Catherine! – Sebastian Roth Dec 22 '14 at 09:57
  • Thanks for the really useful classes. I found that with large recyclerView items (1 screen size in height) scrolling up is oversensitive. This can be fixed by changing a line in the calcPosForVelocity() method: `return (int) Math.max(currPos + tempScroll / childSize, 0);` to `return (int) Math.max(currPos + tempScroll / childSize + 2 , 0);` – Uwais A Jan 23 '15 at 15:21
  • The solution @UwaisA mentioned for near full-screen items worked well for me, as well. – wblaschko Mar 15 '15 at 18:39
  • @UwaisA any idea how can I take offset into account aswell ? if i intend to do scrollToPositionWithOffset(pos,offset) instead of smoothScrollToPosition() ? – ShahrozKhan91 Apr 22 '15 at 06:27
  • 1
    @ShahrozKhan91 no idea sorry, to be honest - I don't understand how the class works, just figured out that was the parameter I needed to vary and did some trial and error. – Uwais A Apr 22 '15 at 08:38
  • @Catherine Could you check my solution? – humblerookie Apr 25 '15 at 10:34
  • @humblerookie sorry, I have been quite busy at work. Will try out your solution sometime this week and let you know. Also, the solution I needed actually snaps to the left side, not to the center (but I assume that I can probably futz around with your code to make it do so) – Catherine Apr 29 '15 at 06:38
  • @Catherine : No probs just thought incase you haven't moved past the problem and had spare time, you could glance around :D – humblerookie May 05 '15 at 09:13
  • Thanks, you just made my day! – Gibberish Oct 08 '15 at 15:01
  • The vertical overscrolling when traveling up the list (negative velocity) can be fixed with the following change: – Brett Duncavage Oct 20 '15 at 23:31
  • I hit enter too early and lost my time to edit my last comment. Here is the change that fixes vertical overscrolling when traveling up the list: ` // If we are flinging down (i.e. going back up the list), the child at 0 is // the child above us so offset for that. if (velocityY < 0) { childPos += 1; } ` I guess writing code in comments is not great :/ – Brett Duncavage Oct 20 '15 at 23:57
  • @Catherine I have dynamic rows and when I scroll up or down, sometimes it loses the power to scroll all the way and it doesn't snap the item to the top but stays about 1/6th from the top screen...it varies tho. Any ideas? – slorangex Dec 23 '15 at 18:34
  • Ok I actually found a "quick fix"... I overriden onScrollStateChanged and `if (state == SCROLL_STATE_IDLE)` I would call your method `smoothScrollToPosition(((ISnapLayoutManager) getLayoutManager()).getFixScrollPos());` Thanks! :) – slorangex Dec 23 '15 at 18:59
  • Seems there is a bug in `getFixScrollPos()`. For `getOrientation() == VERTICAL` it has to be `Math.abs(child.getTop()) > child.getMeasuredHeight() / 2)`. That is **getMeasuredHeight()** instead of **child.getMeasuredWidth()** – Ognyan Dec 26 '15 at 15:52
  • `final double v = Math.sqrt(velocity * velocity);` -- isn't this the same as `final double v = velocity`? – i_am_jorf Feb 01 '16 at 20:32
  • velocity may be negative. It's the same as `Math.abs(velocity)` which is maybe a better choice. – Catherine Feb 01 '16 at 20:35
  • The only place `v` is used is inside of a Math.abs() anyway. – i_am_jorf Feb 01 '16 at 22:26
  • Good point. It's been a while since I wrote this code, so I don't recall what was going through my mind at the time, so I'm not sure if it's a remnant of something that used to make sense or if it was just a clear-cut mistake. I'll edit it. – Catherine Feb 01 '16 at 22:31
  • It also converts it from an int to a double. – i_am_jorf Feb 01 '16 at 22:35
  • Seems like if you are adding ```ItemDecoration``` then to calculate child's left and width (top and height) values correctly you should take into account left/right (top/bottom) decoration width (height). And also if your RecyclerView has paddings you should consider them when calculating top and left values. – Alex Bonel Feb 10 '16 at 07:17
  • Fantastic solution. Thanks so much! A small enhancement for implementers, you can swap out the 160 DPI assumption in calculateDeceleration with `DisplayMetrics.xdpi` and `DisplayMetrics.ydpi` for horizontal and vertical deceleration (requires you to pass what orientation you're decelerating along, but a relatively straightforward). – akdotcom Mar 04 '16 at 15:49
14

I've managed to find a cleaner way to do this. @Catherine (OP) let me know if this can be improved or you feel is an improvement over yours :)

Here's the scroll listener I use.

https://github.com/humblerookie/centerlockrecyclerview/

I've omitted some minor assumptions here like for eg.

1) Initial and final paddings: First and last items in the horizontal scroll need to have initial and final paddings respectively set so that the initial and final views are at center when scrolled to first and last respectively.For eg in the onBindViewHolder you could do something like this.

@Override
public void onBindViewHolder(ReviewHolder holder, int position) {
holder.container.setPadding(0,0,0,0);//Resetpadding
     if(position==0){
//Only one element
            if(mData.size()==1){
                holder.container.setPadding(totalpaddinginit/2,0,totalpaddinginit/2,0);
            }
            else{
//>1 elements assign only initpadding
                holder.container.setPadding(totalpaddinginit,0,0,0);
            }
        }
        else
        if(position==mData.size()-1){
            holder.container.setPadding(0,0,totalpaddingfinal,0);
        } 
}

 public class ReviewHolder extends RecyclerView.ViewHolder {

    protected TextView tvName;
    View container;

    public ReviewHolder(View itemView) {
        super(itemView);
        container=itemView;
        tvName= (TextView) itemView.findViewById(R.id.text);
    }
}

The logic is prettty generic and one can use it for a lot of other cases. My case the recycler view is horizontal and stretches the entire horizontal width without margins( basically recyclerview's center X coordinate is the screen's center)or uneven paddings.

Incase anyone is facing issue kindly comment.

humblerookie
  • 4,717
  • 4
  • 25
  • 40
14

I also needed a snappy recycler view. I want to let the recycler view item snap to the left of a column. It ended up with implementing a SnapScrollListener which I set on the recycler view. This is my code:

SnapScrollListener:

class SnapScrollListener extends RecyclerView.OnScrollListener {

    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        if (RecyclerView.SCROLL_STATE_IDLE == newState) {
            final int scrollDistance = getScrollDistanceOfColumnClosestToLeft(mRecyclerView);
            if (scrollDistance != 0) {
                mRecyclerView.smoothScrollBy(scrollDistance, 0);
            }
        }
    }

}

Calculation of snap:

private int getScrollDistanceOfColumnClosestToLeft(final RecyclerView recyclerView) {
    final LinearLayoutManager manager = (LinearLayoutManager) recyclerView.getLayoutManager();
    final RecyclerView.ViewHolder firstVisibleColumnViewHolder = recyclerView.findViewHolderForAdapterPosition(manager.findFirstVisibleItemPosition());
    if (firstVisibleColumnViewHolder == null) {
        return 0;
    }
    final int columnWidth = firstVisibleColumnViewHolder.itemView.getMeasuredWidth();
    final int left = firstVisibleColumnViewHolder.itemView.getLeft();
    final int absoluteLeft = Math.abs(left);
    return absoluteLeft <= (columnWidth / 2) ? left : columnWidth - absoluteLeft;
}

If the first visible view is scrolled more than the half width out of the screen, the next visible column is snapping to the left.

Setting the listener:

mRecyclerView.addOnScrollListener(new SnapScrollListener());
Thomas R.
  • 7,988
  • 3
  • 30
  • 39
  • 1
    Great method, but before calling smoothScrollBy() you should check if getScrollDistanceOfColumnClosestToLeft() returned non zero value, or you will end up with infinite onScrollStateChanged(SCROLL_STATE_IDLE) calls. – Mihail Ignatiev May 28 '16 at 17:39
8

Here's a simpler hack for smooth scrolling to a certain position on a fling event:

@Override
public boolean fling(int velocityX, int velocityY) {

    smoothScrollToPosition(position);
    return super.fling(0, 0);
}

Override the fling method with a call to smoothScrollToPosition(int position), where "int position" is the position of the view you want in the adapter. You will need to get the value of the position somehow, but that's dependent on your needs and implementation.

eDizzle
  • 573
  • 5
  • 8
6

After messing around with RecyclerView for a bit, this is what I came up with so far and what I'm using right now. It has one minor flaw, but I won't spill the beans (yet) since you probably won't notice.

https://gist.github.com/lauw/fc84f7d04f8c54e56d56

It only supports horizontal recyclerviews and snaps to the center and can also scale down views based on how far they are from the center. Use as a replacement of RecyclerView.

Edit: 08/2016 Made it into a repository:
https://github.com/lauw/Android-SnappingRecyclerView
I'll just keep this up while working on a better implementation.

Lauw
  • 1,715
  • 11
  • 12
  • Thanks! There is a small delay when initially showing the SnappingRecyclerView. The first item starts at the left and then it shows the first item in the center.. Any solution for that? – wouter88 Jun 10 '15 at 09:28
  • Works like a charm! Hoping the implementation is changed to using modern methods provided by `RecyclerView` – Akshay Chordiya Sep 03 '17 at 18:35
  • @wouter88 you can start from another position like this: "mSnappingRecyclerView.getLayoutManager().scrollToPosition(YOUR_POSITION);" – Ali_Ai_Dev May 17 '18 at 19:09
5

A very simple approach for achieving a snap-to-position behavior -

    recyclerView.setOnScrollListener(new OnScrollListener() {
        private boolean scrollingUp;

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            // Or use dx for horizontal scrolling
            scrollingUp = dy < 0;
        }

        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            // Make sure scrolling has stopped before snapping
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                // layoutManager is the recyclerview's layout manager which you need to have reference in advance
                int visiblePosition = scrollingUp ? layoutManager.findFirstVisibleItemPosition()
                        : layoutManager.findLastVisibleItemPosition();
                int completelyVisiblePosition = scrollingUp ? layoutManager
                        .findFirstCompletelyVisibleItemPosition() : layoutManager
                        .findLastCompletelyVisibleItemPosition();
                // Check if we need to snap
                if (visiblePosition != completelyVisiblePosition) {
                    recyclerView.smoothScrollToPosition(visiblePosition);
                    return;
                }

        }
    });

The only small downside is that it will not snap backwards when you scroll less than half way of the partially visible cell - but if this doesn't bother you than it's a clean and simple solution.

Nati Dykstein
  • 1,240
  • 11
  • 19
  • I do not think this would scroll to the center. It (smoothScrollToPosition)merely brings the view to visibile area. – humblerookie Apr 25 '15 at 10:33
4

As described in a previous version of the README for the GravitySnapHelper library:

If you need snapping support to start, top, end or bottom, use GravitySnapHelper.

Snapping center:

SnapHelper snapHelper = new LinearSnapHelper();
snapHelper.attachToRecyclerView(recyclerView);

Snapping start with GravitySnapHelper:

startRecyclerView.setLayoutManager(new LinearLayoutManager(this,
                LinearLayoutManager.HORIZONTAL, false));

SnapHelper snapHelperStart = new GravitySnapHelper(Gravity.START);
snapHelperStart.attachToRecyclerView(startRecyclerView);

Snapping top with GravitySnapHelper:

topRecyclerView.setLayoutManager(new LinearLayoutManager(this));

SnapHelper snapHelperTop = new GravitySnapHelper(Gravity.TOP);
snapHelperTop.attachToRecyclerView(topRecyclerView);
Ryan M
  • 18,333
  • 31
  • 67
  • 74
3

I have implemented a working solution for Horizontal orientation of RecyclerView, that just reads coordinates onTouchEvent, on first MOVE and on UP. On UP calculate the position we need to go to.

public final class SnappyRecyclerView extends RecyclerView {

private Point   mStartMovePoint = new Point( 0, 0 );
private int     mStartMovePositionFirst = 0;
private int     mStartMovePositionSecond = 0;

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 onTouchEvent( MotionEvent e ) {

    final boolean ret = super.onTouchEvent( e );
    final LayoutManager lm = getLayoutManager();
    View childView = lm.getChildAt( 0 );
    View childViewSecond = lm.getChildAt( 1 );

    if( ( e.getAction() & MotionEvent.ACTION_MASK ) == MotionEvent.ACTION_MOVE
            && mStartMovePoint.x == 0) {

        mStartMovePoint.x = (int)e.getX();
        mStartMovePoint.y = (int)e.getY();
        mStartMovePositionFirst = lm.getPosition( childView );
        if( childViewSecond != null )
            mStartMovePositionSecond = lm.getPosition( childViewSecond );

    }// if MotionEvent.ACTION_MOVE

    if( ( e.getAction() & MotionEvent.ACTION_MASK ) == MotionEvent.ACTION_UP ){

        int currentX = (int)e.getX();
        int width = childView.getWidth();

        int xMovement = currentX - mStartMovePoint.x;
        // move back will be positive value
        final boolean moveBack = xMovement > 0;

        int calculatedPosition = mStartMovePositionFirst;
        if( moveBack && mStartMovePositionSecond > 0 )
            calculatedPosition = mStartMovePositionSecond;

        if( Math.abs( xMovement ) > ( width / 3 )  )
            calculatedPosition += moveBack ? -1 : 1;

        if( calculatedPosition >= getAdapter().getItemCount() )
            calculatedPosition = getAdapter().getItemCount() -1;

        if( calculatedPosition < 0 || getAdapter().getItemCount() == 0 )
            calculatedPosition = 0;

        mStartMovePoint.x           = 0;
        mStartMovePoint.y           = 0;
        mStartMovePositionFirst     = 0;
        mStartMovePositionSecond    = 0;

        smoothScrollToPosition( calculatedPosition );
    }// if MotionEvent.ACTION_UP

    return ret;
}}

Works fine for me, let me know if something is wrong.

Michail L
  • 31
  • 2
2

To update humblerookie's answer:

This scroll listener is indeed effective for centerlocking https://github.com/humblerookie/centerlockrecyclerview/

But here is a simpler way to add padding at the start and end of the recyclerview to center its elements:

mRecycler.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
            int childWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, CHILD_WIDTH_IN_DP, getResources().getDisplayMetrics());
            int offset = (mRecycler.getWidth() - childWidth) / 2;

            mRecycler.setPadding(offset, mRecycler.getPaddingTop(), offset, mRecycler.getPaddingBottom());

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                mRecycler.getViewTreeObserver().removeOnGlobalLayoutListener(this);
            } else {
                mRecycler.getViewTreeObserver().removeGlobalOnLayoutListener(this);
            }
        }
    });
Gomino
  • 12,127
  • 4
  • 40
  • 49
  • 2
    Hey @gomino, glad you found it handy but the padding suggested by you would reduce the scroll area of the recyclerview. Adding padding to the children(first and the last) however would work fruitfully. – humblerookie May 18 '15 at 11:51
  • Easy @humblerookie just add `android:clipToPadding="false"` and `android:clipChildren="false"` to the recyclerview in the xml layout file – Gomino May 18 '15 at 11:57
1

And yet another cleaner option is to use custom LayoutManager, you can check https://github.com/apptik/multiview/tree/master/layoutmanagers

It's under development but working quite well. A snapshot is available: https://oss.sonatype.org/content/repositories/snapshots/io/apptik/multiview/layoutmanagers/

Example:

recyclerView.setLayoutManager(new SnapperLinearLayoutManager(getActivity()));
Tom11
  • 2,419
  • 8
  • 30
  • 56
kalin
  • 3,546
  • 2
  • 25
  • 31
  • The problem with your approach is that it all extends LinearLayoutManager. What if you need any other LayoutManager. This is the big advantage of attaching a scroll listener and a custom scroller. You can attach it to any LayoutManager you want. Otherwise, it would be a nice solution, your LayoutManagers are nice and clean. – Michał Klimczak Mar 16 '16 at 20:26
  • Thx! you're right that having a scroll listener could be more flexible is many cases. I choose to do it for LinearLayoutManager only as I didn't find the practical need to use it for a grid or staggered anyway and I find it naturally that this behavior is handled by the LayoutManager. – kalin Mar 16 '16 at 22:32
-1

I needed something a little bit different than all of the answers above.

The main requirements were that:

  1. It works the same when user flings or just releases his finger.
  2. Uses the native scrolling mechanism to have the same "feeling" as a regular RecyclerView.
  3. When it stops, it starts smooth scrolling to the nearest snap point.
  4. No need to use custom LayoutManager or RecyclerView. Just a RecyclerView.OnScrollListener, which is then attached by recyclerView.addOnScrollListener(snapScrollListener). This way the code is much cleaner.

And two very specific requirements which should be easy to change in the example below to fit to your case:

  1. Works horizontally.
  2. Snaps left edge of item to a specific point in the RecyclerView.

This solution uses the native LinearSmoothScroller. The difference is that in the final step, when the "target view" is found it changes the calculation of offset so that it snaps to a specific position.

public class SnapScrollListener extends RecyclerView.OnScrollListener {

private static final float MILLIS_PER_PIXEL = 200f;

/** The x coordinate of recycler view to which the items should be scrolled */
private final int snapX;

int prevState = RecyclerView.SCROLL_STATE_IDLE;
int currentState = RecyclerView.SCROLL_STATE_IDLE;

public SnapScrollListener(int snapX) {
    this.snapX = snapX;
}

@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    super.onScrollStateChanged(recyclerView, newState);
    currentState = newState;
    if(prevState != RecyclerView.SCROLL_STATE_IDLE && currentState == RecyclerView.SCROLL_STATE_IDLE ){
        performSnap(recyclerView);
    }
    prevState = currentState;

}

private void performSnap(RecyclerView recyclerView) {
    for( int i = 0 ;i < recyclerView.getChildCount() ; i ++ ){
        View child = recyclerView.getChildAt(i);
        final int left = child.getLeft();
        int right = child.getRight();
        int halfWidth = (right - left) / 2;
        if (left == snapX) return;
        if (left - halfWidth <= snapX && left + halfWidth >= snapX) { //check if child is over the snapX position
            int adapterPosition = recyclerView.getChildAdapterPosition(child);
            int dx = snapX - left;
            smoothScrollToPositionWithOffset(recyclerView, adapterPosition, dx);
            return;
        }
    }
}

private void smoothScrollToPositionWithOffset(RecyclerView recyclerView, int adapterPosition, final int dx) {
    final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
    if( layoutManager instanceof LinearLayoutManager) {

        LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) {
            @Override
            public PointF computeScrollVectorForPosition(int targetPosition) {
                return ((LinearLayoutManager) layoutManager).computeScrollVectorForPosition(targetPosition);
            }

            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
                final int distance = (int) Math.sqrt(dx * dx + dy * dy);
                final int time = calculateTimeForDeceleration(distance);
                if (time > 0) {
                    action.update(-dx, -dy, time, mDecelerateInterpolator);
                }
            }

            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return MILLIS_PER_PIXEL / displayMetrics.densityDpi;
            }
        };

        scroller.setTargetPosition(adapterPosition);
        layoutManager.startSmoothScroll(scroller);

    }
}
Michał Klimczak
  • 12,674
  • 8
  • 66
  • 99
  • this code has many tricky points where scroll scenarios will not be handled correctly and also will have different behavior on different screens. it also counter to the philosophy of the 'RecyclerView' where you actually restrict the option for custom scroller. – kalin Mar 14 '16 at 20:18
  • 1. What are the tricky scenarios? I could not think of one that I didn't mention at the very beginning (and which is easy to handle if you need to handle it). 2.It doesn't have different behaviour on different screens, what makes you think that it does? 3. Can you provide me with link to the philosophy of RecyclerView and where it says that we should use LayoutManager instead of scroller? Attaching a scroller is in a public API, so I have absolutely no idea what you're talking about. Did you read the code, because I don't think so ;) – Michał Klimczak Mar 16 '16 at 20:15
  • 0) in general i like your idea of having a scroll listener as its more flexible in many cases. 1) i will not go in details but onScrollStateChanged which is calling performSnap will be called again by the smooth scroller action which can lead to StackOverflow on the other hand u never check for the orientation which leads to serious issues and considering the recursive call above definite StackOverflow. 2) u use pixels to get the position its better to use % especially when the user rotate the screen then most definitely the the snap point would not be expected one. – kalin Mar 16 '16 at 22:24
  • 3) RV is super modular and this is why it is so good. attaching SmoothScroller is available but you tightly link yours with the snapping which removed this flexibility it is also available only for LinearLayoutManager instances which removes the good flexibility of your idea. i read the code :) {cheers} – kalin Mar 16 '16 at 22:28