Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Vertical RecyclerView nested inside vertical RecyclerView

I have spent hours/days reading around this subject but still can't find something that works. I'm trying to put a fixed-height vertically-scrolling RecyclerView in the row of another vertically-scrolling RecyclerView.

Much of the advice is along the lines of "it's a crime to put a vertically-scrolling RecyclerView inside another vertically-scrolling RecyclerView"... but I can't figure out why this is so bad.

In fact, the behavior would be almost exactly the same as many pages on StackOverflow (e.g. this one... and indeed this very question, at least when viewed on a mobile device), where the code sections are of a fixed (or max) height, and scroll vertically, and are contained within a page that itself scrolls vertically. What happens is that when the focus is on the code section, scrolling happens within that section, and when it reaches the upper/lower end of the scroll range of the section, then scrolling happens within the outer page. It's quite natural, not evil.

This is my recycler_view_row_outer.xml (a row within the outer RecyclerView):

<com.google.android.material.card.MaterialCardView
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    style="@style/MyCardView"
    app:cardElevation="4dp"
    app:strokeColor="?attr/myCardBorderColor"
    app:strokeWidth="0.7dp"
    card_view:cardCornerRadius="8dp" >

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical" >

        <TextView
            style="@style/MyTextView.Section"
            android:id="@+id/list_title" />

        <LinearLayout
            style="@style/MyLinearLayoutContainer"
            android:id="@+id/list_container"
            android:layout_below="@+id/list_title" >
        </LinearLayout>

    </RelativeLayout>

</com.google.android.material.card.MaterialCardView>

And this is my recycler_view_row_inner.xml (a row within the inner RecyclerView):

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/my_constraint_layout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_below="@+id/list_container" >

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view_inner"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:scrollbarStyle="outsideOverlay"
        android:scrollbars="vertical"
        app:layout_constrainedHeight="true"
        app:layout_constraintBottom_toBottomOf="@+id/my_constraint_layout"
        app:layout_constraintEnd_toEndOf="@+id/my_constraint_layout"
        app:layout_constraintHeight_max="750dp"
        app:layout_constraintHeight_min="0dp"
        app:layout_constraintStart_toStartOf="@+id/my_constraint_layout"
        app:layout_constraintTop_toTopOf="@+id/my_constraint_layout" >
    </androidx.recyclerview.widget.RecyclerView>

</androidx.constraintlayout.widget.ConstraintLayout>

With the above inner layout, I've tried to follow the approach set out in this post, to create an inner RecyclerView having a fixed/max height... but it isn't working.

I inflate the inner layout (added to list_container/containerView in the outer layout) and set up my inner recyclerView as follows:

View inflatedView = getLayoutInflater().inflate(R.layout.recycler_view_row_inner, containerView, false);
RecyclerView recyclerView = inflatedView.findViewById(R.id.recycler_view_inner);
// set adapter, row data, etc

But all this does is create a fixed-height inner row that does not scroll within the outer row... overflow content of the inner row is just cut off and I can't reach the lower part of it because it just scrolls the outer row.

Any idea how to make this work?

like image 545
drmrbrewer Avatar asked Dec 22 '22 15:12

drmrbrewer


1 Answers

Approach No.1: Using two nested RecyclerViews

Github sample

Pros:

  • Views are recycled (i.e. Good performance)
  • Semi-Seamless scrolling (after update 3 & 4)

Cons:

  • The programmatically propagated scroll during the transition from the inner to the outer scroll when the inner far end item is reached is not that smooth/natural like the gesture.
  • Complex code.

Well, I won't address the performance issues of vertically nested RecyclerViews; But notice that:

  • The inner RecyclerView probably loses the ability of recycling views; because the shown rows of the outer recyclerView should load their items entirely. (Thankfully it's not a right assumption as per the below UPDATE 1)
  • I declared a single adapter instance in the ViewHolder not in the onBindViewHolder to have a better performance by not creating a new adapter instance for the inner RecyclerView each time views are recycled.

The demo app represents the months of the year as the outer RecyclerView, and the day numbers of each month as inner RecyclerView.

The outer RecyclerView registers OnScrollListener that each time it's scrolled, we do this check on the inner RV:

  • If outer scrolling up: check if the inner first item is shown.
  • If outer scrolling down: check if the inner last item is shown.
    outerRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {

        @Override
        public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
            if (dy > 0) //scrolled to BOTTOM
                outerAdapter.isOuterScrollingDown(true, dy);
            else if (dy < 0) //scrolled to TOP
                outerAdapter.isOuterScrollingDown(false, dy);
        }
    });

In the outer adapter:

    public void isOuterScrollingDown(boolean scrollDown, int value) {
        if (scrollDown) {
            boolean isLastItemShown = currentLastItem == mMonths.get(currentPosition).dayCount;
            if (!isLastItemShown) onScrollListener.onScroll(-value);
            enableOuterScroll(isLastItemShown);

        } else {
            boolean isFirstItemShown = currentFirstItem == 1;
            if (!isFirstItemShown) onScrollListener.onScroll(-value);
            enableOuterScroll(isFirstItemShown);
        }
        if (currentRV != null)
            currentRV.smoothScrollBy(0, 10 * value);
    }

If the relevant item is not shown, then we decide to disable the outer RV scrolling. This is handled by a listener with a callback that accepts a boolean passed to a customized LinearLayoutManager class to the outer RV.

Likewise in order to re-enable scrolling of the outer RV: the inner RecyclerView registers OnScrollListener to check if the inner first/last item is shown.

innerRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {

    @Override
    public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {

        if (!recyclerView.canScrollVertically(1) // Is it not possible to scroll more to bottom (i.e. Last item shown)
                && newState == RecyclerView.SCROLL_STATE_IDLE) {
            enableOuterScroll(true);

        } else if (!recyclerView.canScrollVertically(-1) // Is it possible to scroll more to top (i.e. First item shown)
                && newState == RecyclerView.SCROLL_STATE_IDLE) {
            enableOuterScroll(true);
        }
    }
});

Still, there are glitches because disabling/enabling scrolling; we can't pass the scroll order to the other RV, until the next scroll. This is manipulated slightly by reversing the initial outer RV scroll value; and using an arbitrary scroll value to the inner with currentRV.smoothScrollBy(0, 10 * initialScroll). I wish if someone can suggest any other alternative to this.

UPDATE 1

  • The inner RecyclerView probably lose the ability of recycling views; because the shown rows of the outer recyclerView should load their items entirely.

Thankfully it's not the right assumption and the views are recycled by tracking the recycled list of items in the inner adapter using a List that hold the currently loaded items:

By assuming some month has a 1000 days "Feb as it's always oppressed :)", and scrolling up/down to notice the loaded list and make sure that onViewRecycled() get called.

public class InnerRecyclerAdapter extends RecyclerView.Adapter<InnerRecyclerAdapter.InnerViewHolder> {

    private final ArrayList<Integer> currentLoadedPositions = new ArrayList<>();

    @Override
    public void onBindViewHolder(@NonNull InnerViewHolder holder, int position) {
        holder.tvDay.setText(String.valueOf(position + 1));
        currentLoadedPositions.add(position);
        Log.d(LOG_TAG, "onViewRecycled: " + days + " " + currentLoadedPositions);
    }

    @Override
    public void onViewRecycled(@NonNull InnerViewHolder holder) {
        super.onViewRecycled(holder);
        currentLoadedPositions.remove(Integer.valueOf(holder.getAdapterPosition()));
        Log.d(LOG_TAG, "onViewRecycled: " + days + " " + currentLoadedPositions);
    }
    
    // Rest of code is trimmed

}

Logs:

onViewRecycled: 1000 [0]
onViewRecycled: 1000 [0, 1]
onViewRecycled: 1000 [0, 1, 2]
onViewRecycled: 1000 [0, 1, 2, 3]
onViewRecycled: 1000 [0, 1, 2, 3, 4]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5, 6]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5, 6, 7]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5, 6, 7, 8]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
onViewRecycled: 1000 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
onViewRecycled: 1000 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
onViewRecycled: 1000 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
onViewRecycled: 1000 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
onViewRecycled: 1000 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
onViewRecycled: 1000 [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
onViewRecycled: 1000 [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
onViewRecycled: 1000 [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
onViewRecycled: 1000 [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
onViewRecycled: 1000 [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
onViewRecycled: 1000 [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
onViewRecycled: 1000 [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
onViewRecycled: 1000 [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
onViewRecycled: 1000 [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
onViewRecycled: 1000 [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
onViewRecycled: 1000 [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
onViewRecycled: 1000 [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
onViewRecycled: 1000 [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
onViewRecycled: 1000 [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
onViewRecycled: 1000 [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
onViewRecycled: 1000 [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
onViewRecycled: 1000 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
onViewRecycled: 1000 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
onViewRecycled: 1000 [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
onViewRecycled: 1000 [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
onViewRecycled: 1000 [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
onViewRecycled: 1000 [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
onViewRecycled: 1000 [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
onViewRecycled: 1000 [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]

UPDATE 2

Still there are glitches because disabling/enabling scrolling; we can't pass the scroll order to the other RV, until the next scroll. This is manipulated slightly by reversing the initial outer RV scroll value; and using an arbitrary scroll value to the inner with currentRV.smoothScrollBy(0, 10 * initialScroll). I wish if someone can suggest any other alternative to this.

  • Using a greater arbitrary value (like 30) makes the grammatical scroll looks smoother >> currentRV.smoothScrollBy(0, 30 * initialScroll)

  • And scrolling the outer scroll without reversing the scroll, makes it also looks more natural in the same direction of the scroll:

if (scrollDown) {
    boolean isLastItemShown = currentLastItem == mMonths.get(currentPosition).dayCount;
    if (!isLastItemShown) onScrollListener.onScroll(value);
    enableOuterScroll(isLastItemShown);

} else {
    boolean isFirstItemShown = currentFirstItem == 1;
    if (!isFirstItemShown) onScrollListener.onScroll(value);
    enableOuterScroll(isFirstItemShown);
}

UPDATE 3

Issue: glitches during the transition from the outer to inner RecyclerView because the onScroll() of the outer gets called before deciding whether we can scroll the inner or not.

By using OnTouchListener to the outer RecyclerView and overriding onTouch() and return true to consume the event (so that onScrolled() won't get called) until we decide that the inner can take the scroll over.

private float oldY = -1f;
outerRecyclerView.setOnTouchListener((v, event) -> {
    Log.d(LOG_TAG, "onTouch: ");
    switch (event.getAction()) {
        case MotionEvent.ACTION_UP:
            oldY = -1;
            break;

        case MotionEvent.ACTION_MOVE:
            float newY = event.getRawY();
            Log.d(LOG_TAG, "onTouch: MOVE " + (oldY - newY));

            if (oldY == -1f) {
                oldY = newY;
                return true; // avoid further listeners (i.e. addOnScrollListener)

            } else if (oldY < newY) { // increases means scroll UP
                outerAdapter.isOuterScrollingDown(false, (int) (oldY - newY));
                oldY = newY;

            } else if (oldY > newY) { // decreases means scroll DOWN
                outerAdapter.isOuterScrollingDown(true, (int) (oldY - newY));
                oldY = newY;
            }
            break;
    }
    return false;
});

UPDATE 4

  • Enabling the scroll transition from the inner to outer RecyclerView whenever the inner RV scrolled to its top or bottom edge so that it continues scrolling to the outer RV by a proportional speed.

The scroll speed is inspired by this post. By applying the first/last item checks in the touched inner RV's OnTouchListener & OnScrollListener , and resetting stuff in a brand new touch event i.e. in MotionEvent.ACTION_DOWN

  • Disable over-scroll mode in both inner & outer RecyclerViews

Preview:

Approach No.2: Wrapping outer RecyclerView in NestedScrollView

Github sample

The main issue of the nested scrolling of a RecyclerView is that it doesn't implement NestedScrollingParent3 interface which is implemented by NestedScrollView; So RecyclerView can't handle nested scrolling of child views. So, trying to compensate that with a NestedScrollView by wrapping the outer RecyclerView within a NestedScrollView, and disable the scrolling of the outer RecyclerView

Pros:

  • Simple code (You don't have to manipulate inner/outer scrolling at all)
  • No glitch
  • Seamless scrolling

Cons:

  • Low performance as views of the outer RecyclerView are not recycled and so that they have to be all loaded before showing up on the screen.

Reason: Due to the nature of the NestedScrollView >> Check (1), (2) questions that discussed the recycling issues:

Approach No.3: Using ViewPager2 as the outer RecyclerView

Github sample

Using a ViewPager2 that functions internally using a RecyclerView solves the problem of recycling views, but only one page (one outer row) can present at a time.

Pros:

  • No glitches & Seamless scrolling upon using NestedScrollableHost
  • Views are recycled as there is an internal RecyclerView in ViewPager2

Cons:

  • Showing only a single item per page

So we probably tackle this by researching either:

  • How to show Multiple views per page
  • How to wrap_content a page
like image 80
Zain Avatar answered Mar 15 '23 23:03

Zain