package com.dant.centersnapreyclerview; import android.content.Context; import android.content.res.TypedArray; import android.support.annotation.IntDef; import android.support.annotation.Nullable; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.view.View; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.ref.WeakReference; /** * An extension of RecyclerView which provides the ability for an anchor to which views will snap * too. Once a scroll has been completed the RecyclerView will calculate which of it's Views * (ViewHolders) is closest to it's anchor and, using the smoothScroll method provided by * CenterLayoutManager, scroll said View to the correct position on screen. */ public class SnappingRecyclerView extends RecyclerView { @IntDef({HORIZONTAL, VERTICAL}) @Retention(RetentionPolicy.SOURCE) public @interface OrientationMode {} public static final int VERTICAL = 0; public static final int HORIZONTAL = 1; @IntDef({CENTER, START, END}) @Retention(RetentionPolicy.SOURCE) public @interface AnchorMode {} public static final int CENTER = 0; public static final int START = 1; public static final int END = 2; private static final int DEFAULT_FLING_THRESHOLD = 1000; public interface SnappingRecyclerViewListener { void onPositionChange(int position); void onScroll(int dx, int dy); } private WeakReference<SnappingRecyclerViewListener> listener; private int orientation; /** * The anchor to which a View (ViewHolder) should snap too, the START, CENTER or END */ private int anchor; /** * The smooth scroll speed, in ms per inch, this is 100 by default in our custom smooth * scroller */ private float scrollSpeed; /** * If the velocity of the user's fling is below a set threshold, finish fling and scroll to the * appropriate View */ private int flingThreshold; private CenterLayoutManager layoutManager; public SnappingRecyclerView(Context context) { super(context); initialise(context, null); } public SnappingRecyclerView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); initialise(context, attrs); } public SnappingRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); initialise(context, attrs); } private void initialise(Context context, @Nullable AttributeSet attrs) { TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SnappingRecyclerView, 0, 0); try { orientation = a.getInt(R.styleable.SnappingRecyclerView_orientation, VERTICAL); anchor = a.getInt(R.styleable.SnappingRecyclerView_anchor, CENTER); scrollSpeed = a.getFloat(R.styleable.SnappingRecyclerView_scrollSpeed, -1); flingThreshold = a.getInt(R.styleable.SnappingRecyclerView_flingThreshold, DEFAULT_FLING_THRESHOLD); } finally { a.recycle(); } layoutManager = new CenterLayoutManager(getContext()); layoutManager.setOrientation(orientation == VERTICAL ? LinearLayoutManager.VERTICAL : LinearLayoutManager.HORIZONTAL); layoutManager.setAnchor(anchor); layoutManager.setScrollSpeed(scrollSpeed); setLayoutManager(layoutManager); } public SnappingRecyclerViewListener getListener() { return listener != null ? listener.get() : null; } public void setListener(SnappingRecyclerViewListener listener) { this.listener = new WeakReference<>(listener); } public void setOrientation(@OrientationMode int orientation) { if (this.orientation != orientation) { this.orientation = orientation; layoutManager.setOrientation(orientation == VERTICAL ? LinearLayoutManager.VERTICAL : LinearLayoutManager.HORIZONTAL); requestLayout(); } } @OrientationMode public int getOrientation() { return orientation; } public void setAnchor(@AnchorMode int anchor) { if (this.anchor != anchor) { this.anchor = anchor; layoutManager.setAnchor(anchor); requestLayout(); } } @AnchorMode public int getAnchor() { return anchor; } @Override public void onScrolled(int dx, int dy) { super.onScrolled(dx, dy); if(getListener() != null) { getListener().onScroll(dx, dy); } } @Override public void scrollToPosition(final int position) { super.scrollToPosition(position); // Use the smoothScroll provided by the CenterLayoutManager post(new Runnable() { @Override public void run() { smoothScrollToPosition(position); } }); } @Override public boolean fling(int velocityX, int velocityY) { if(Math.abs(orientation == VERTICAL ? velocityY : velocityX) < flingThreshold) { int centerViewPosition = calculateSnapViewPosition(); smoothScrollToPosition(centerViewPosition); if(getListener() != null) { getListener().onPositionChange(centerViewPosition); } return true; } else { return super.fling(velocityX, velocityY); } } @Override public void onScrollStateChanged(int state) { super.onScrollStateChanged(state); /* Once a scroll has been completed smooth scroll to the nearest View based on the anchor */ if (state == SCROLL_STATE_IDLE) { int centerViewPosition = calculateSnapViewPosition(); smoothScrollToPosition(centerViewPosition); if(getListener() != null) { getListener().onPositionChange(centerViewPosition); } } } /** * Provides the anchor of the parent, the RecyclerView, based on the provided orientation and * anchor mode * * @param orientation The orientation of the RecyclerView, VERTICAL or HORIZONTAL * @param anchor The RecyclerView's anchor mode, START, CENTER or END * * @return The anchor of the parent, the RecyclerView */ private int getParentAnchor(@OrientationMode int orientation, @AnchorMode int anchor) { switch (anchor) { case START: return 0; case END: return orientation == VERTICAL ? getHeight() : getWidth(); case CENTER: default: return (orientation == VERTICAL ? getHeight() : getWidth()) / 2; } } /** * Provides the anchor or the given view relative to the provided orientation and anchor. * This will be the View's start (top or left), center, or end (bottom or right). * * @param view * @param orientation The orientation of the RecyclerView, VERTICAL or HORIZONTAL * @param anchor The RecyclerView's anchor mode, START, CENTER or END * * @return The anchor of the given View relative to the provided orientation and anchor */ private int getViewAnchor(View view, @OrientationMode int orientation, @AnchorMode int anchor) { switch (anchor) { case START: return orientation == VERTICAL ? view.getTop() : view.getLeft(); case END: return orientation == VERTICAL ? view.getBottom() : view.getRight(); case CENTER: default: return (orientation == VERTICAL ? view.getTop() + (view.getHeight() / 2) : view.getLeft() + (view.getWidth() / 2)); } } /** * Calculates the distance between the RecyclerView's anchor, either the start, center or end, * and the View which is closest to the anchor. * * @return The distance between RecyclerView anchor and View closest to anchor */ private int calculateSnapDistance() { LinearLayoutManager linearLayoutManager = (LinearLayoutManager) getLayoutManager(); int parentAnchor = getParentAnchor(orientation, anchor); int lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition(); int firstVisibleItemPosition = linearLayoutManager.findFirstVisibleItemPosition(); View currentViewClosestToAnchor = linearLayoutManager.findViewByPosition(firstVisibleItemPosition); int currentViewClosestToAnchorDistance = parentAnchor - getViewAnchor(currentViewClosestToAnchor, orientation, anchor); for(int i = firstVisibleItemPosition + 1; i <= lastVisibleItemPosition; i++) { View view = linearLayoutManager.findViewByPosition(i); int distanceToAnchor = parentAnchor - getViewAnchor(view, orientation, anchor); if (Math.abs(distanceToAnchor) < Math.abs(currentViewClosestToAnchorDistance)) { currentViewClosestToAnchorDistance = distanceToAnchor; } } return currentViewClosestToAnchorDistance; } /** * Finds the position of the View which is closest to the RecyclerView's anchor, either the * RecyclerView's start, center or end * * @return The position of the View closest the to RecyclerView's anchor */ private int calculateSnapViewPosition() { LinearLayoutManager linearLayoutManager = (LinearLayoutManager) getLayoutManager(); int parentAnchor = getParentAnchor(orientation, anchor); int lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition(); int firstVisibleItemPosition = linearLayoutManager.findFirstVisibleItemPosition(); View currentViewClosestToAnchor = linearLayoutManager.findViewByPosition(firstVisibleItemPosition); int currentViewClosestToAnchorDistance = parentAnchor - getViewAnchor(currentViewClosestToAnchor, orientation, anchor); int currentViewClosestToAnchorPosition = firstVisibleItemPosition; for(int i = firstVisibleItemPosition + 1; i <= lastVisibleItemPosition; i++) { View view = linearLayoutManager.findViewByPosition(i); int distanceToCenter = parentAnchor - getViewAnchor(view, orientation, anchor); if (Math.abs(distanceToCenter) < Math.abs(currentViewClosestToAnchorDistance)) { currentViewClosestToAnchorPosition = i; currentViewClosestToAnchorDistance = distanceToCenter; } } return currentViewClosestToAnchorPosition; } }