I have a ViewPager showing a fragment per page. This fragment holds a list of items inside a RecyclerView. The list of items is always the same size and the views for the items are also of the same height. When scrolling one of the RecyclerViews, I want the other RecyclerViews to scroll at the same time and the same distance. How would I synchronise the scrolling of the RecyclerViews?
Sync scrolling of multiple RecyclerViews
19.9k views Asked by gerokeller AtThere are 9 answers
Create variable in your pager adapter which will responsible to save current scrollY
position for its pages and whenever your getItem(position)
will call update ListView
positoin have a look at CacheFragmentStatePagerAdapter
in
https://github.com/ksoichiro/Android-ObservableScrollView/blob/master/samples/src/main/java/com/github/ksoichiro/android/observablescrollview/samples/ViewPagerTabActivity.java
kotlin version, this one doesn't handle fling (yet)
class ScrollSynchronizer {
val boundRecyclerViews: ArrayList<RecyclerView> = ArrayList()
var verticalOffset = 0
private val scrollListener = object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
// scroll every other rv to the same position
boundRecyclerViews.filter { it != recyclerView }.forEach { targetView ->
targetView.removeOnScrollListener(this)
targetView.scrollBy(dx, dy)
targetView.addOnScrollListener(this)
}
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
verticalOffset = recyclerView.computeVerticalScrollOffset()
}
}
}
fun add(view: RecyclerView) {
with (view) {
if (boundRecyclerViews.contains(view)) {
removeOnScrollListener(scrollListener)
boundRecyclerViews.remove(this)
}
addOnScrollListener(scrollListener)
boundRecyclerViews.add(this)
}
}
fun scrollToLastOffset(rv: RecyclerView) {
rv.removeOnScrollListener(scrollListener)
val currentOffset = rv.computeVerticalScrollOffset()
if (currentOffset != verticalOffset) {
rv.scrollBy(0, verticalOffset - currentOffset)
}
rv.addOnScrollListener(scrollListener)
}
fun disable() = boundRecyclerViews.forEach { it.removeOnScrollListener(scrollListener) }
fun enable() = boundRecyclerViews.forEach { it.addOnScrollListener(scrollListener) }
fun addAll(recyclerViews: List<RecyclerView>) = recyclerViews.forEach { add(it) }
}
EDIT: following @Groovee60 comment & recommendation, replace the onScrolled
with this:
// scroll every other rv to the same position
val otherRvs = boundRecyclerViews.filter { it != recyclerView }
otherRvs.forEach { targetView -> targetView.removeOnScrollListener(this) }
otherRvs.forEach { targetView -> targetView.scrollBy(dx, dy) }
otherRvs.forEach { targetView -> targetView.addOnScrollListener(this) }
I think I have found a very easy and short answer.
as Jorge Antonio Díaz-Benito said "The first option that comes to mind is listening for scroll changes on both ScrollViews and, when one of them scrolls, use scrollBy(int x, int y) on the other one. Unfortunately, programmatically scrolling will also trigger the listener, so you'll end up in a loop."
So you need to fix this issue. If you just keep track of who is scrolling they won't loop.
SOLUTION
public class SelfScrolListener extends RecyclerView.OnScrollListener {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
viewIsScrolling = -1;
}
}
}
This is your custom OnScrollListener to check if the scrollState is IDLE. is that is true -> no one is scrolling. so the `int viewIsScolling = -1
now you need to detect if you can scroll. this is the code:
int viewIsScrolling = 1;
boolean firstIsTouched = false;
boolean secondIsTouched = false;
SelfScrolListener firstOSL= new SelfScrolListener() {
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
if (firstIsTouched) {
if (viewIsScrolling == -1) {
viewIsScrolling = 0;
}
if (viewIsScrolling == 0) {
secondRecyclerView.scrollBy(dx, dy);
}
}
}
};
SelfScrolListener secondOSL= new SelfScrolListener() {
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
if(secondIsTouched){
if (viewIsScrolling == -1) {
viewIsScrolling = 1;
}
if (viewIsScrolling == 1) {
firstRecyclerView.scrollBy(dx, dy);
}
}
}
};
firstRecyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
firstIsTouched= true;
return false;
}
});
secondRecyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
secondIsTouched= true;
return false;
}
});
firstRecyclerView.addOnScrollListener(firstOSL);
secondRecyclerView.addOnScrollListener(secondOSL);
viewIsScrolling = a global int and set in the beginning to -1; the state that no one is scrolling. you can ad as many recycleviews as you want.
How about this?
fun start(main: RecyclerView, attach: RecyclerView) {
attach.setOnTouchListener { _, motionEvent ->
main.dispatchTouchEvent(motionEvent)
true
}
main.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
attach.scrollBy(dx, dy)
}
})
}
First of all consider just using NestedScrollView
as parent for RecyclerViews
. This might need some additional tweaks but general idea is the same:
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</android.support.v4.widget.NestedScrollView>
If you can't do this for some reasons, you can sync scroll programmatically. Just need to avoid infinite loop of onScrolled()
event callback as already mentioned in other answers. In other words when you started scrolling programmatically you do nothing inside onScroll()
until you finished scrolling programmatically. And do not scroll programmatically RecyclerView
that was originally scrolled.
Put all Recyclers for sync in
List<RecyclerView> syncRecyclers
Call
addSyncListeners()
Enjoy
public class SyncScrollActivity extends AppCompatActivity {
private List<RecyclerView> syncRecyclers;
private boolean isProgrammaticallyScrolling = false;
private void addSyncListeners() {
for (RecyclerView recyclerView : syncRecyclers) {
recyclerView.addOnScrollListener(new SyncOnScrollListener());
}
}
private class SyncOnScrollListener extends RecyclerView.OnScrollListener {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (!isProgrammaticallyScrolling) {
isProgrammaticallyScrolling = true;
scrollAll(recyclerView, dx, dy);
isProgrammaticallyScrolling = false;
}
}
}
private void scrollAll(RecyclerView exceptRecycler, int dx, int dy) {
for (RecyclerView recyclerView : syncRecyclers) {
if (!recyclerView.equals(exceptRecycler)) {
recyclerView.scrollBy(dx, dy);
}
}
}
}
I believe it is relevant for you to understand its workings, so I am going to explain the whole procedure that I followed to design my solution. Note that this example is for only two RecyclerViews, but doing it with more is as easy as using an array of RecyclerViews.
The first option that comes to mind is listening for scroll changes on both ScrollViews and, when one of them scrolls, use scrollBy(int x, int y) on the other one. Unfortunately, programmatically scrolling will also trigger the listener, so you'll end up in a loop.
To overcome this problem, you will need to setup an OnItemTouchListener that adds the proper ScrollListener when a RecyclerView is touched, and removes it when the scrolling stops. This works almost flawlessly, but if you perform a quick fling in a long RecyclerView, and then scroll it again before it finishes, only the first scroll will be transferred.
To work around this, you will need to ensure that the OnScrollListener is only added when the RecyclerView is idle.
Let's take a look at the source:
public class SelfRemovingOnScrollListener extends RecyclerView.OnScrollListener {
@Override
public final void onScrollStateChanged(@NonNull final RecyclerView recyclerView, final int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
recyclerView.removeOnScrollListener(this);
}
}
}
This is the class from which you need to extend your OnScrollListeners. This ensures that they are removed when needed.
Then I have the two listeners, one for each RecyclerView:
private final RecyclerView.OnScrollListener mLeftOSL = new SelfRemovingOnScrollListener() {
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
mRightRecyclerView.scrollBy(dx, dy);
}
}, mRightOSL = new SelfRemovingOnScrollListener() {
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
mLeftRecyclerView.scrollBy(dx, dy);
}
};
And then upon initialization you can setup the OnItemTouchListeners. It would be better to set up a single listener for the whole view instead, but RecyclerView does not support this. OnItemTouchListeners don't pose a problem anyway:
mLeftRecyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
private int mLastY;
@Override
public boolean onInterceptTouchEvent(@NonNull final RecyclerView rv, @NonNull final
MotionEvent e) {
Log.d("debug", "LEFT: onInterceptTouchEvent");
final Boolean ret = rv.getScrollState() != RecyclerView.SCROLL_STATE_IDLE;
if (!ret) {
onTouchEvent(rv, e);
}
return Boolean.FALSE;
}
@Override
public void onTouchEvent(@NonNull final RecyclerView rv, @NonNull final MotionEvent e) {
Log.d("debug", "LEFT: onTouchEvent");
final int action;
if ((action = e.getAction()) == MotionEvent.ACTION_DOWN && mRightRecyclerView
.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) {
mLastY = rv.getScrollY();
rv.addOnScrollListener(mLeftOSL);
}
else {
if (action == MotionEvent.ACTION_UP && rv.getScrollY() == mLastY) {
rv.removeOnScrollListener(mLeftOSL);
}
}
}
@Override
public void onRequestDisallowInterceptTouchEvent(final boolean disallowIntercept) {
Log.d("debug", "LEFT: onRequestDisallowInterceptTouchEvent");
}
});
mRightRecyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
private int mLastY;
@Override
public boolean onInterceptTouchEvent(@NonNull final RecyclerView rv, @NonNull final
MotionEvent e) {
Log.d("debug", "RIGHT: onInterceptTouchEvent");
final Boolean ret = rv.getScrollState() != RecyclerView.SCROLL_STATE_IDLE;
if (!ret) {
onTouchEvent(rv, e);
}
return Boolean.FALSE;
}
@Override
public void onTouchEvent(@NonNull final RecyclerView rv, @NonNull final MotionEvent e) {
Log.d("debug", "RIGHT: onTouchEvent");
final int action;
if ((action = e.getAction()) == MotionEvent.ACTION_DOWN && mLeftRecyclerView
.getScrollState
() == RecyclerView.SCROLL_STATE_IDLE) {
mLastY = rv.getScrollY();
rv.addOnScrollListener(mRightOSL);
}
else {
if (action == MotionEvent.ACTION_UP && rv.getScrollY() == mLastY) {
rv.removeOnScrollListener(mRightOSL);
}
}
}
@Override
public void onRequestDisallowInterceptTouchEvent(final boolean disallowIntercept) {
Log.d("debug", "RIGHT: onRequestDisallowInterceptTouchEvent");
}
});
}
Note also that, in my particular case, the RecyclerViews are not the first ones to receive the touch event, so I need to intercept it. If this is not your case, you can (should) merge the code from onInterceptTouchEvent(...) into onTouchEvent(...).
Finally, this will cause a crash if your user attempts to scroll your two RecyclerViews at the same time. The best effort-quality solution possible here is to set android:splitMotionEvents="false"
in the direct parent containing the RecyclerViews.
You can see an example with this code here.
Here was my solution. The less the code the better...
lvDetail & lvDetail2 are the RecyclerViews you want to keep in sync.
final RecyclerView.OnScrollListener[] scrollListeners = new RecyclerView.OnScrollListener[2];
scrollListeners[0] = new RecyclerView.OnScrollListener( )
{
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy)
{
super.onScrolled(recyclerView, dx, dy);
lvDetail2.removeOnScrollListener(scrollListeners[1]);
lvDetail2.scrollBy(dx, dy);
lvDetail2.addOnScrollListener(scrollListeners[1]);
}
};
scrollListeners[1] = new RecyclerView.OnScrollListener( )
{
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy)
{
super.onScrolled(recyclerView, dx, dy);
lvDetail.removeOnScrollListener(scrollListeners[0]);
lvDetail.scrollBy(dx, dy);
lvDetail.addOnScrollListener(scrollListeners[0]);
}
};
lvDetail.addOnScrollListener(scrollListeners[0]);
lvDetail2.addOnScrollListener(scrollListeners[1]);
This solution handles fling. Demo project here
Suppose you have three horizontal RecyclerViews.
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv0"
android:layout_width="match_parent"
android:layout_height="@dimen/itemSize"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv1"
android:layout_width="match_parent"
android:layout_height="@dimen/itemSize"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="horizontal"
android:layout_marginTop="5dp"
app:layout_constraintTop_toBottomOf="@id/rv0"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv2"
android:layout_width="match_parent"
android:layout_height="@dimen/itemSize"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="horizontal"
android:layout_marginTop="5dp"
app:layout_constraintTop_toBottomOf="@id/rv1"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
- Add tag to each RecyclerView. For example:
val rvs = arrayListOf(rv0, rv1, rv2)
for (i in 0 until rvs.size) {
rvs[i].tag = i
}
- Add the same OnItemTouchListener to each RecyclerView to know which RecyclerView is active. If there is already an active RecyclerView then call stopScroll on it (it prevents out of sync if active RecyclerView in fling state)
var touchedRVTag = -1
val itemTouchListener = object : RecyclerView.OnItemTouchListener {
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {}
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean
{
val rvTag = rv.tag as Int
if (touchedRVTag != -1 && touchedRVTag != rvTag) {
rvs[touchedRVTag].stopScroll()
}
touchedRVTag = rvTag
return false
}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
}
for (i in 0 until rvs.size) {
rvs[i].addOnItemTouchListener(itemTouchListener)
}
- Add the same OnScrollListener to each RecyclerView to scroll other RecyclerViews
val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val currentRVTag = recyclerView.tag as Int
if (currentRVTag == touchedRVTag) {
for (rvTag in 0 until rvs.size) {
if (rvTag != currentRVTag) {
rvs[rvTag].scrollBy(dx, 0)
}
}
}
}
}
for (i in 0 until rvs.size) {
rvs[i].addOnScrollListener(scrollListener)
}
PS: If you want to scroll any RecyclerView programmatically set its tag and then scroll:
touchedRVTag = rv0.tag as Int
rv0.scrollBy(dx, 0)
I found a very easy solution without any flags and it is quite dynamic
The first thing is you have to create
RecyclerView.OnScrollListener
and subscribe that listener to each horizontal layout that you wish.RecyclerView.OnScrollListener
:Please note that you have to have RecyclerView with the same
layout.id
. Useful in case of vertical RecyclerView with horizontal rows