android developer
android developer

Reputation: 116322

How to sync scrolling of all horizontal nested RecyclerViews within a vertical RecyclerViews?

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 ?

Upvotes: 2

Views: 1557

Answers (1)

Malik
Malik

Reputation: 3802

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)

Upvotes: 1

Related Questions