2

Background

Suppose I have a vertical RecyclerView, where each row is a horizontal RecyclerView.

What I'd like to do is that no matter which horizontal RecyclerViews you scroll, all of the others will scroll accordingly, and always be synced with the exact same scroll X coordinate

The problem

I actually did ok for the basic operation :

enter image description here

It works by having a scrolling listener that all horizontal RecyclerViews have, yet when one starts to scroll, it is the only one that will have it, while it also affects the others to scroll with it.

However, I have 2 main issues with what I did:

  1. In some (horizontal) scrolling operations (maybe some gestures, like fling), the scrolling of the multiple RecyclerViews is out of sync, so some are in X coordinate that is different from the others.

  2. When scrolling vertically, I couldn't succeed setting the X coordinate correctly. Not only that, but onBindViewHolder of the vertical RecyclerView doesn't get called when I expected it to be called (called when I scroll a lot, and not just when I see a used one being re-shown).

What I've tried

Here's the current code:

MainActivity.java

public class MainActivity extends AppCompatActivity {
    int mCurX = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final RecyclerView mainRecyclerView = (RecyclerView) findViewById(R.id.activity_main);
        final LinearLayoutManager verticalLinearLayoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false);
        mainRecyclerView.setLayoutManager(verticalLinearLayoutManager);
        final LayoutInflater layoutInflater = LayoutInflater.from(this);
        final OnScrollListener masterOnScrollListener = new OnScrollListener() {
            RecyclerView masterRecyclerView = null;

            @Override
            public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) {
                super.onScrollStateChanged(recyclerView, newState);
                switch (newState) {
                    case RecyclerView.SCROLL_STATE_IDLE:
                        if (masterRecyclerView != null) {
                            masterRecyclerView = null;
                            final int firstVisibleItemPosition = verticalLinearLayoutManager.findFirstVisibleItemPosition();
                            final int lastVisibleItemPosition = verticalLinearLayoutManager.findLastVisibleItemPosition();
                            for (int i = firstVisibleItemPosition; i <= lastVisibleItemPosition; ++i) {
                                RecyclerView horizontalRecyclerView = (RecyclerView) mainRecyclerView.findViewHolderForAdapterPosition(i).itemView;
                                if (horizontalRecyclerView != recyclerView)
                                    horizontalRecyclerView.addOnScrollListener(this);
                            }
                        }
                        break;
                    case RecyclerView.SCROLL_STATE_SETTLING:
                        //TODO fix out-of-sync scrolling issues, probably here
                    case RecyclerView.SCROLL_STATE_DRAGGING:
                        if (masterRecyclerView == null) {
                            masterRecyclerView = recyclerView;
                            final int firstVisibleItemPosition = verticalLinearLayoutManager.findFirstVisibleItemPosition();
                            final int lastVisibleItemPosition = verticalLinearLayoutManager.findLastVisibleItemPosition();
                            for (int i = firstVisibleItemPosition; i <= lastVisibleItemPosition; ++i) {
                                RecyclerView horizontalRecyclerView = (RecyclerView) mainRecyclerView.findViewHolderForAdapterPosition(i).itemView;
                                if (horizontalRecyclerView != recyclerView)
                                    horizontalRecyclerView.removeOnScrollListener(this);
                            }
                        }
                }
            }

            @Override
            public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
                super.onScrolled(recyclerView, dx, dy);
                mCurX += dx;
                final int firstVisibleItemPosition = verticalLinearLayoutManager.findFirstVisibleItemPosition();
                final int lastVisibleItemPosition = verticalLinearLayoutManager.findLastVisibleItemPosition();
                for (int i = firstVisibleItemPosition; i <= lastVisibleItemPosition; ++i) {
                    RecyclerView horizontalRecyclerView = (RecyclerView) mainRecyclerView.findViewHolderForAdapterPosition(i).itemView;
                    if (horizontalRecyclerView != recyclerView)
                        horizontalRecyclerView.scrollBy(dx, dy);
                }
            }
        };
        mainRecyclerView.setAdapter(new Adapter() {
            @Override
            public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
                RecyclerView horizontalRecyclerView = (RecyclerView) layoutInflater.inflate(R.layout.horizontal_recycler_view, parent, false);
                horizontalRecyclerView.setLayoutManager(new LinearLayoutManager(MainActivity.this, LinearLayoutManager.HORIZONTAL, false));
                horizontalRecyclerView.addOnScrollListener(masterOnScrollListener);
                final ViewHolder horizontalViewHolder = new ViewHolder(horizontalRecyclerView) {
                };
                horizontalRecyclerView.setAdapter(new Adapter() {
                    @Override
                    public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
                        return new ViewHolder(layoutInflater.inflate(R.layout.single_item, parent, false)) {
                        };
                    }

                    @Override
                    public void onBindViewHolder(final ViewHolder holder, final int position) {
                        ((TextView) holder.itemView).setText("horizontalRecyclerView:" + horizontalViewHolder.getAdapterPosition() + "\nitem:" + position);
                    }

                    @Override
                    public int getItemCount() {
                        return 100;
                    }
                });
                return horizontalViewHolder;
            }

            @Override
            public void onBindViewHolder(final ViewHolder holder, final int position) {
                //TODO check why this isn't called for some cases
                RecyclerView recyclerView = (RecyclerView) holder.itemView;
                recyclerView.removeOnScrollListener(masterOnScrollListener);
                //TODO scroll to correct location here. The below code doesn't seem to work at all
                recyclerView.scrollToPosition(0);
                recyclerView.scrollBy(mCurX,0);
                recyclerView.addOnScrollListener(masterOnScrollListener);
                recyclerView.getAdapter().notifyDataSetChanged();
            }

            @Override
            public int getItemCount() {
                return 40;
            }
        });
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
    android:id="@+id/activity_main"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="lb.com.nestedallscrollingrecyclerviewtest.MainActivity"/>

horizontal_recycler_view.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="100dp"/>

single_item.xml

<?xml version="1.0" encoding="utf-8"?>
<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="10dp"/>

The questions

  1. What is wrong in the code that causes it to be out-of-scrolling sync?

  2. Is it possible I also don't get a hold of all the RecyclerViews that I should?

  3. How come the onBindViewHolder of the vertical RecyclerView doesn't get called when I expect it to?

  4. How do I set the x-coordinate scrolling of a horizontal RecyclerView to be as the others, in onBindViewHolder of the vertical one?

  5. I'm not sure if this could be a problem, but what should I do in case each item in each horizontal RecyclerView could be with a different width than the others ?

android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • I don't know answer for your questions, but I've done smth very similar some time ago. It was commercial project and currently I don't have access to code, but I was using manual dipatching `MotionEvent`s between all `View`s properly and everything was working fine... BUT now I have some doubts about this, probably this feature wasn't working with "other kinds" of navigation e.g. arrows on keyboard... (not so proud of that currently) maybe sharing `KeyEvent`s will be sufficient? just a clue, good luck! – snachmsm Dec 15 '16 at 21:18

1 Answers1

1

A bit late to the party but just putting it here in case anyone else stumbles upon the same issue. Please Note that this solution is written in Kotlin and you might have to convert it to Java if that is your language of choice.

Solution

There are a couple of issues that need to be taken into account.

  1. Synchronise scrolling of horizontal recycler views
  2. Retain offset when scrolling vertical recycler view

Add this code in the Adapter for your vertical recycler view

var horizontalRecyclerViews = mutableListOf<RecyclerView>()
var absoluteOffset: Int? = null    //Used to solve issue number 2

// matchOffset is used to synchronise the offset of each horizontal recyclerview.
// It is called when a horizontal recyclerview is scrolled with that recyclerview's
// offset. It is also called when the vertical recycler view is scrolled but without
// an offset value (in which case, it uses the absoluteOffset which is set when
// the horizontal scrolling is stopped) 
fun matchOffset(offset: Int? = absoluteOffset) {
    offset?.let { offsetValue ->
        horizontalRecyclerViews.forEach { recyclerView ->
            val currentOffset = recyclerView.computeHorizontalScrollOffset()
            if (offsetValue != currentOffset) {
                recyclerView.scrollBy(offsetValue-currentOffset, 0)
            }
        }
    }
}

override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
    ...
    ...
    ...

    val onTouchListener = object: RecyclerView.OnItemTouchListener {
        override fun onTouchEvent(p0: RecyclerView, p1: MotionEvent) {
        }
        override fun onInterceptTouchEvent(p0: RecyclerView, p1: MotionEvent): Boolean {
            if (p1.action == MotionEvent.ACTION_UP) {
                // This value is used by the vertical recycler view
                absoluteOffset = p0.computeHorizontalScrollOffset()

                // Disable the fling scroll to make life easier
                return true
            }
            return false
        }
        override fun onRequestDisallowInterceptTouchEvent(p0: Boolean) {
        }
    }

    val onScrollListener = object: RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            val value = recyclerView.computeHorizontalScrollOffset()
            matchOffset(value)
        }
    }

    ...
    ...
    ...

    //Clear scroll listeners on each bind to stop them from accumulating
    horizontalRecyclerView.clearOnScrollListeners()

    //Add touch and scroll listeners to horizontalRecyclerView
    horizontalRecyclerView.addOnItemTouchListener(onTouchListener)
    horizontalRecyclerView.addOnScrollListener(onScrollListener)

    //Add each horizontal recyclerView into the mutableList
    horizontalRecyclerViews.add(horizontalRecyclerView)

    ...
    ...
    ...
}

To wrap it up for the resolution of issue number 2, add the following scroll listener to your vertical recycler view

val onScrollListener = object: RecyclerView.OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)

        //Cast the Adapter to access the matchOffset method
        (recyclerView.adapter as? Adapter)?.matchOffset()
    }
}

verticalRecyclerView.addOnScrollListener(onScrollListener)
Malik
  • 3,763
  • 1
  • 22
  • 35
  • I've got to other stuff since I asked this. Can you please post a sample project (via github) to try it? This way I could at least give you +1 if it works nicely... – android developer Nov 29 '18 at 07:34
  • 1
    Here's a very poorly constructed sample project (I'm more of an iOS person than Android lol). https://github.com/malikaferoze/AndroidSynchronisedScrolling – Malik Nov 29 '18 at 09:05
  • Seems to work fine, but the more items it has, the slower it gets. Try 100X100 items, and you will see how slow it can get. It doesn't even let me do a fast-scroll gesture . – android developer Nov 29 '18 at 23:19
  • 1
    As I said, Android is not my main field. This is something I came up with as a basis to solve an issue (in case someone else is in the same position). I'm sure someone more fluent in Android would be able to improve the performance of it. Maybe use caching of some sort – Malik Nov 30 '18 at 00:36
  • I think I've found some causes for it: usage of list of RecycerView, which keeps growing. Usage of a lot of creation code in onBindViewHolder instead of onCreateViewHolder, onTouchListener that doesn't seem to do anything. I actually tried in your code to sync the horizontal offset for all, but when reaching a new one, it got reset. Only by having the parent responsible it worked fine, but this is still not as efficient, because most of the scroll events should be ignored (all are synced anyway). Here's the code after changes: http://s000.tinyupload.com/?file_id=78741647718648126658 – android developer Nov 30 '18 at 07:42
  • I think you did a good job, but not as efficient as it should. If you somehow manage to handle the vertical scrolling better than what I ended in doing for your code, it would be great. There is no reason that the parent recyclerView will tell all of the RecyclerViews to sync on every tiny scroll. I think it should be in the adapter itself, when you reach a new RecyclerView (so that it syncs with the others, without the need to even go over all of them). – android developer Nov 30 '18 at 07:45