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);
}
}
@lauw
Copy link
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
Copy link

Pkmmte commented Apr 14, 2016

soonbackanswer

@lauw
Copy link
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
Copy link

johnkil commented Apr 20, 2016

I want ))

@aemxn
Copy link

aemxn 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 😄

@mkwlf
Copy link

mkwlf 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
Copy link
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.

@aliraza96
Copy link

aliraza96 commented Mar 19, 2020

Hi everyone,
This is a perfect solution indeed to achieve this functionality in your app. But I wanted to ask a question, that how to get the centered item id. I mean how to know which view is centered in the recycler view? Kindly any help will be appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment