Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Snapping RecyclerView (Horizontal)
/*
* Copyright 2015 Laurens Muller.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.muller.snappingrecyclerview;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import static android.widget.AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL;
import static android.widget.AbsListView.OnScrollListener.SCROLL_STATE_FLING;
public class SnappingRecyclerView extends RecyclerView {
private boolean mSnapEnabled = false;
private boolean mUserScrolling = false;
private boolean mScrolling = false;
private int mScrollState;
private long lastScrollTime = 0;
private Handler mHandler = new Handler();
private boolean mScaleUnfocusedViews = false;
private final static int MINIMUM_SCROLL_EVENT_OFFSET_MS = 20;
public SnappingRecyclerView(Context context) {
super(context);
}
public SnappingRecyclerView(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* Enable snapping behaviour for this recyclerView
* @param enabled enable or disable the snapping behaviour
*/
public void setSnapEnabled(boolean enabled) {
mSnapEnabled = enabled;
if (enabled) {
addOnLayoutChangeListener(new OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
if (left == oldLeft && right == oldRight && top == oldTop && bottom == oldBottom) {
removeOnLayoutChangeListener(this);
updateViews();
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
scrollToView(getChildAt(0));
}
}, 20);
}
}
});
setOnScrollListener(new OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
updateViews();
super.onScrolled(recyclerView, dx, dy);
}
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
/** if scroll is caused by a touch (scroll touch, not any touch) **/
if (newState == SCROLL_STATE_TOUCH_SCROLL) {
/** if scroll was initiated already, this is not a user scrolling, but probably a tap, else set userScrolling **/
if (!mScrolling) {
mUserScrolling = true;
}
} else if (newState == SCROLL_STATE_IDLE) {
if (mUserScrolling) {
scrollToView(getCenterView());
}
mUserScrolling = false;
mScrolling = false;
} else if (newState == SCROLL_STATE_FLING) {
mScrolling = true;
}
mScrollState = newState;
}
});
} else {
setOnScrollListener(null);
}
}
/**
* Enable snapping behaviour for this recyclerView
* @param enabled enable or disable the snapping behaviour
* @param scaleUnfocusedViews downScale the views which are not focused based on how far away they are from the center
*/
public void setSnapEnabled(boolean enabled, boolean scaleUnfocusedViews) {
this.mScaleUnfocusedViews = scaleUnfocusedViews;
setSnapEnabled(enabled);
}
private void updateViews() {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
setMarginsForChild(child);
if (mScaleUnfocusedViews) {
float percentage = getPercentageFromCenter(child);
float scale = 1f - (0.7f * percentage);
child.setScaleX(scale);
child.setScaleY(scale);
}
}
}
/**
* Adds the margins to a childView so a view will still center even if it's only a single child
* @param child childView to set margins for
*/
private void setMarginsForChild(View child) {
int lastItemIndex = getLayoutManager().getItemCount() - 1;
int childIndex = getChildPosition(child);
int startMargin = childIndex == 0 ? getMeasuredWidth() / 2 : 0;
int endMargin = childIndex == lastItemIndex ? getMeasuredWidth() / 2 : 0;
/** if sdk minimum level is 17, set RTL margins **/
if (Build.VERSION.SDK_INT >= 17) {
((ViewGroup.MarginLayoutParams) child.getLayoutParams()).setMarginStart(startMargin);
((ViewGroup.MarginLayoutParams) child.getLayoutParams()).setMarginEnd(endMargin);
}
((ViewGroup.MarginLayoutParams) child.getLayoutParams()).setMargins(startMargin, 0, endMargin, 0);
child.requestLayout();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (!mSnapEnabled)
return super.dispatchTouchEvent(event);
long currentTime = System.currentTimeMillis();
/** if touch events are being spammed, this is due to user scrolling right after a tap,
* so set userScrolling to true **/
if (mScrolling && mScrollState == SCROLL_STATE_TOUCH_SCROLL) {
if ((currentTime - lastScrollTime) < MINIMUM_SCROLL_EVENT_OFFSET_MS) {
mUserScrolling = true;
}
}
lastScrollTime = currentTime;
View targetView = getChildClosestToPosition((int)event.getX());
if (!mUserScrolling) {
if (event.getAction() == MotionEvent.ACTION_UP) {
if (targetView != getCenterView()) {
scrollToView(targetView);
return true;
}
}
}
return super.dispatchTouchEvent(event);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
if (!mSnapEnabled)
return super.onInterceptTouchEvent(e);
View targetView = getChildClosestToPosition((int) e.getX());
if (targetView != getCenterView()) {
return true;
}
return super.onInterceptTouchEvent(e);
}
private View getChildClosestToPosition(int x) {
if (getChildCount() <= 0)
return null;
int itemWidth = getChildAt(0).getMeasuredWidth();
int closestX = 9999;
View closestChild = null;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
int childCenterX = ((int)child.getX() + (itemWidth / 2));
int xDistance = childCenterX - x;
/** if child center is closer than previous closest, set it as closest **/
if (Math.abs(xDistance) < Math.abs(closestX)) {
closestX = xDistance;
closestChild = child;
}
}
return closestChild;
}
private View getCenterView() {
return getChildClosestToPosition(getMeasuredWidth() / 2);
}
private void scrollToView(View child) {
if (child == null)
return;
stopScroll();
int scrollDistance = getScrollDistance(child);
if (scrollDistance != 0)
smoothScrollBy(scrollDistance, 0);
}
private int getScrollDistance(View child) {
int itemWidth = getChildAt(0).getMeasuredWidth();
int centerX = getMeasuredWidth() / 2;
int childCenterX = ((int)child.getX() + (itemWidth / 2));
return childCenterX - centerX;
}
private float getPercentageFromCenter(View child) {
float centerX = (getMeasuredWidth() / 2);
float childCenterX = child.getX() + (child.getWidth() / 2);
float offSet = Math.max(centerX, childCenterX) - Math.min(centerX, childCenterX);
int maxOffset = (getMeasuredWidth() / 2) + child.getWidth();
return (offSet / maxOffset);
}
public boolean isChildCenterView(View child) {
return child == getCenterView();
}
public int getHorizontalScrollOffset() {
return computeHorizontalScrollOffset();
}
public int getVerticalScrollOffset() {
return computeVerticalScrollOffset();
}
public void smoothUserScrollBy(int x, int y) {
mUserScrolling = true;
smoothScrollBy(x, y);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mHandler.removeCallbacksAndMessages(null);
}
}
@nitinOAB

This comment has been minimized.

Copy link

nitinOAB commented Sep 30, 2015

superb this is what we want but this is for horizontal only can u tell how to make this work in verticle ?

@hozur

This comment has been minimized.

Copy link

hozur commented Feb 21, 2016

This is very helpful for me .But how to make it to "pullRighttoRefresh"?

@beersheba

This comment has been minimized.

Copy link

beersheba commented Feb 29, 2016

I found a small bug for use with RTL layouts (Hebrew, Arabic). Need to swipe startMargin and endMargin like this:

private void setMarginsForChild(View child) {
    int lastItemIndex = getLayoutManager().getItemCount() - 1;
    int childIndex = getChildPosition(child);

    int startMargin = childIndex == 0 ? getMeasuredWidth() / 2 : 0;
    int endMargin = childIndex == lastItemIndex ? getMeasuredWidth() / 2 : 0;

    //RTL works for API 17+
    if (ViewCompat.getLayoutDirection(child) == ViewCompat.LAYOUT_DIRECTION_RTL) {
        // The view has RTL layout
        ((ViewGroup.MarginLayoutParams) child.getLayoutParams()).setMargins(endMargin, 0, startMargin, 0);
    } else {
        // The view has LTR layout
        ((ViewGroup.MarginLayoutParams) child.getLayoutParams()).setMargins(startMargin, 0, endMargin, 0);
    }

    child.requestLayout();
@abbasshah17

This comment has been minimized.

Copy link

abbasshah17 commented Mar 18, 2016

Hi, while using this extension of RecyclerView I came across a bug.

Say you changed Adapter's Data Set and say your previously selected item was the first one in the list then if new Data Set takes less space on screen than Measured Width then no ScrollListener is called. I don't know why perhaps this is a bug in RecyclerView it-self or maybe something is missing in code.

My current workaround is to update Views in onLayout() and that is very expensive.
I'd like to know if there's a way to either update Views when Data Set has changed, or something else entirely.

EDIT :
Never mind found another way. Just called recyclerView.setAdapter();. But can anyone explain why I need to call setAdapter again when I have only changed Data Set in Adapter not the Adapter it-self.

@jrh4016

This comment has been minimized.

Copy link

jrh4016 commented Mar 22, 2016

So I'm stuck trying to figure out how to get back the functionality to swipe right* past the beginning of the list to return to a previous activity. Right now with RecyclerView, once at the beginning of a list I can swipe left and it will close the current activity. Returning me to the previous activity. However with this SnappingRecyclerView, swiping left past the beginning of the list does nothing

@murthyanitaa

This comment has been minimized.

Copy link

murthyanitaa commented Mar 30, 2016

Hi I seem to be having a problem where there are gaps between the last item in the list and the second last item. There is a gap between the first item on the list and the second item. By gaps I mean there is a white space between the list items. I figured that it has something to do with the following lines:
int startMargin = childIndex == 0 ? getMeasuredWidth() / 2 : 0;
int endMargin = childIndex == lastItemIndex ? getMeasuredWidth() / 2 : 0;
but I'm not sure how to fix it. Can you please help?

screenshot_2016-03-30-22-30-20

@lauw

This comment has been minimized.

Copy link
Owner Author

lauw commented Mar 31, 2016

Hey guys, unfortunately I did not receive any notification about these comments.
I am not that active in the android scene anymore, however after posting this gist I did change a few things in this class to fix a few of the bugs you have been experiencing.
I will try to post an update tonight with some fixes to the class. Possibly in a repository so you can post your issues and receive help there.
-Update:
Am working on this but is a bit more work than expected. So expect it sometime soon.

@Pkmmte

This comment has been minimized.

Copy link

Pkmmte commented Apr 14, 2016

soonbackanswer

@lauw

This comment has been minimized.

Copy link
Owner Author

lauw commented Apr 20, 2016

Haha yeah :(
I did start, but I will finish up now that I know at least somebody wants it ;) I have to say it seems pretty sh*tty though. So let's improve it after.

@johnkil

This comment has been minimized.

Copy link

johnkil commented Apr 20, 2016

I want ))

@aimanbaharum

This comment has been minimized.

Copy link

aimanbaharum commented Apr 21, 2016

Having somewhat the same issue as @murthyanitaa

I have 3 items in my horizontal RecyclerView and I'm setting scaleUnfocusedViews to true. Upon first scrolling, the first and last item will be redrawn to have gaps (left and right) and it seems like the width is squeezed. However the items between it remains the same, the only issue is, it loses the original margin set in the xml.

Screenshot: http://i.imgur.com/sVtUFai.jpg
Red area is the background color of the RecyclerView.

Anyhow, nice trick. Waiting for the fix 😄

@xiberger

This comment has been minimized.

Copy link

xiberger commented Apr 27, 2016

first of all thx for sharing!
do you know when you will be finished?
and will you provide it as a gradle dependency?

further can you change the access to your private methods to protected, so they are usable in a subclass?

  • cheers
@lauw

This comment has been minimized.

Copy link
Owner Author

lauw commented Aug 16, 2016

Decided to finally push it: https://github.com/lauw/Android-SnappingRecyclerView
Am also slowly working on a new version to achieve a similar effect but without replacing RecyclerView, implementing it as a LayoutManager.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.