drmrbrewer
drmrbrewer

Reputation: 13109

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?

Upvotes: 8

Views: 10614

Answers (5)

TheWanderer
TheWanderer

Reputation: 17874

Cheticamp's answer works really well for cases where you don't need an ItemTouchHelper that supports dragging.

For that case, I came up with a messy but functional workaround:

class NestedRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr), NestedScrollingParent3 {
    private var nestedScrollTarget: View? = null
    private var nestedScrollTargetWasUnableToScroll = false
    private val parentHelper by lazy { NestedScrollingParentHelper(this) }

    /**
     * Set this wherever you have access to your item touch helper instance.
     * Using `attachToRecyclerView(null)` resets any long-press timers.
     *
     * Example:
     *
     * nestedRecyclerView.nestedScrollingListener = {
     *      itemTouchHelper.attachToRecyclerView(if (!it) nestedRecyclerView else null)
     * }
     */
    var nestedScrollingListener: ((Boolean) -> Unit)? = null

    /**
     * Set this from your item touch helper callback to let the RecyclerView
     * know when an item is selected (prevents an inverse nested scrolling issue
     * where the nested view scrolls and the item touch helper doesn't receive
     * further callbacks).
     *
     * Example:
     * override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
     *      if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) nestedRecyclerView.selectedItem = true
     *      ...
     * }
     * ...
     * override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
     *      nestedRecyclerView.selectedItem = false
     *      ...
     * }
     */
    var selectedItem: Boolean = false

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        // Nothing special if no child scrolling target.
        if (nestedScrollTarget == null || selectedItem) return super.dispatchTouchEvent(ev)

        // Inhibit the execution of our onInterceptTouchEvent for now...
        requestDisallowInterceptTouchEvent(true)
        // ... but do all other processing.
        var handled = super.dispatchTouchEvent(ev)

        // If the first dispatch yielded an unhandled event or the descendant view is unable to
        // scroll in the direction the user is scrolling, we dispatch once more but without skipping
        // our onInterceptTouchEvent. Note that RecyclerView automatically cancels active touches of
        // all its descendants once it starts scrolling so we don't have to do that.
        requestDisallowInterceptTouchEvent(false)
        if (ev.action == MotionEvent.ACTION_UP) {
            // This is to prevent an issue where the item touch helper receives
            // an ACTION_DOWN but then doesn't later get the ACTION_UP event,
            // causing it to run any long-press events.
            nestedScrollingListener?.invoke(true)
            nestedScrollingListener?.invoke(false)
        }
        if (!handled || nestedScrollTargetWasUnableToScroll) {
            handled = super.dispatchTouchEvent(ev)
        }

        return handled
    }

    override fun getNestedScrollAxes(): Int {
        return parentHelper.nestedScrollAxes
    }

    // We only support vertical scrolling.
    override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int) =
        nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0

    /*  Introduced with NestedScrollingParent2. */
    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int) =
        onStartNestedScroll(child, target, axes)

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int) {
        if (axes and View.SCROLL_AXIS_VERTICAL != 0) {
            // A descendant started scrolling, so we'll observe it.
            setTarget(target)
        }
        parentHelper.onNestedScrollAccepted(child, target, axes)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        if (axes and View.SCROLL_AXIS_VERTICAL != 0) {
            // A descendant started scrolling, so we'll observe it.
            setTarget(target)
        }
        parentHelper.onNestedScrollAccepted(child, target, axes, type)
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
        super.onNestedPreScroll(target, dx, dy, consumed)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        onNestedPreScroll(target, dx, dy, consumed)
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int
    ) {
        if (target === nestedScrollTarget && dyUnconsumed != 0) {
            // The descendant could not fully consume the scroll. We remember that in order
            // to allow the RecyclerView to take over scrolling.
            nestedScrollTargetWasUnableToScroll = true
            // Let the parent start to consume scroll events.
            target.parent?.requestDisallowInterceptTouchEvent(false)
        }
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int
    ) {
        onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
    }

    /*  Introduced with NestedScrollingParent3. */
    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int,
        consumed: IntArray
    ) {
        onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type)
    }

    /* From ViewGroup */
    override fun onStopNestedScroll(child: View) {
        // The descendant finished scrolling. Clean up!
        setTarget(null)
        parentHelper.onStopNestedScroll(child)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onStopNestedScroll(target: View, type: Int) {
        // The descendant finished scrolling. Clean up!
        setTarget(null)
        parentHelper.onStopNestedScroll(target, type)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
        return super.onNestedPreFling(target, velocityX, velocityY)
    }

    /* In ViewGroup for API 21+. */
    override fun onNestedFling(
        target: View,
        velocityX: Float,
        velocityY: Float,
        consumed: Boolean
    ) = super.onNestedFling(target, velocityX, velocityY, consumed).also {
        // If the nested fling wasn't consumed, then the touch helper can act.
        // Otherwise, disable it.
        nestedScrollingListener?.invoke(!it)
    }

    private fun setTarget(target: View?) {
        if (target == null) {
            // We're not nested scrolling anymore so the touch helper can
            // do its thing again.
            nestedScrollingListener?.invoke(false)
        }
        nestedScrollTarget = target
        nestedScrollTargetWasUnableToScroll = false

        // My specific implementation has nested ListViews (AppWidgetHost), so this is
        // also needed. If you have scrollable Views in general, you may need to use
        // View#setOnScrollChangeListener and check if the scroll change is non-zero.
        (target as? ListView)?.setOnScrollListener(object : AbsListView.OnScrollListener {
            override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {
                nestedScrollingListener?.invoke(scrollState != AbsListView.OnScrollListener.SCROLL_STATE_IDLE)
            }

            override fun onScroll(
                view: AbsListView?,
                firstVisibleItem: Int,
                visibleItemCount: Int,
                totalItemCount: Int
            ) {
            }
        })
    }
}

You need direct access to the NestedRecyclerView instance where you have your ItemTouchHelper and its callback in order to listen for and set different states. I've commented the new properties I added.

Upvotes: 0

Cheticamp
Cheticamp

Reputation: 62851

Here is an approach that retrofits RecyclerView with the NestedScrollingParent3 interface. The NestedRecyclerView class is used as the outer RecyclerView and a stock RecyclerView is used as an inner item view.

The following code is based upon code from Widgetlabs here which is licensed under the MIT License. The code presented below has been heavily modified from the original.

Operation When the outer RecyclerView is touched, it can be scrolled up and down as expected and the inner RecyclerView is dragged along.

When the inner RecyclerView is touched, it can be scrolled up and down without effecting the outer RecyclerView. When the inner RecyclerView reaches it fullest extent either down or up, the outer RecyclerView starts to scroll. Once the outer RecyclerView starts to scroll, it has captured the gesture and will not release it until another "down" event. This is different from how RecyclerView behaves within a NestedScrollView where the outer RecyclerView scrolls but inner RecyclerView will resume consuming the scroll gesture when the scroll direction changes. In essence, the inner RecyclerView never relinquishes control while here it does.

The following video demonstrates the operation of NestedRecyclerView. In the video, the inner RecyclerView has a white border and orange and red rows. Everthing else is the outer RecyclerView.

enter image description here

NestedRecyclerView

open class NestedRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr), NestedScrollingParent3 {

    private var nestedScrollTarget: View? = null
    private var nestedScrollTargetWasUnableToScroll = false
    private val parentHelper by lazy { NestedScrollingParentHelper(this) }

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        // Nothing special if no child scrolling target.
        if (nestedScrollTarget == null) return super.dispatchTouchEvent(ev)

        // Inhibit the execution of our onInterceptTouchEvent for now...
        requestDisallowInterceptTouchEvent(true)
        // ... but do all other processing.
        var handled = super.dispatchTouchEvent(ev)

        // If the first dispatch yielded an unhandled event or the descendant view is unable to
        // scroll in the direction the user is scrolling, we dispatch once more but without skipping
        // our onInterceptTouchEvent. Note that RecyclerView automatically cancels active touches of
        // all its descendants once it starts scrolling so we don't have to do that.
        requestDisallowInterceptTouchEvent(false)
        if (!handled || nestedScrollTargetWasUnableToScroll) {
            handled = super.dispatchTouchEvent(ev)
        }

        return handled
    }

    // We only support vertical scrolling.
    override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int) =
        nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0

    /*  Introduced with NestedScrollingParent2. */
    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int) =
        onStartNestedScroll(child, target, axes)

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int) {
        if (axes and View.SCROLL_AXIS_VERTICAL != 0) {
            // A descendant started scrolling, so we'll observe it.
            setTarget(target)
        }
        parentHelper.onNestedScrollAccepted(child, target, axes)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        if (axes and View.SCROLL_AXIS_VERTICAL != 0) {
            // A descendant started scrolling, so we'll observe it.
            setTarget(target)
        }
        parentHelper.onNestedScrollAccepted(child, target, axes, type)
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            dispatchNestedPreScroll(dx, dy, consumed, null)
        } else {
            super.onNestedPreScroll(target, dx, dy, consumed)
        }
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        onNestedPreScroll(target, dx, dy, consumed)
    }

    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int
    ) {
        if (target === nestedScrollTarget && dyUnconsumed != 0) {
            // The descendant could not fully consume the scroll. We remember that in order
            // to allow the RecyclerView to take over scrolling.
            nestedScrollTargetWasUnableToScroll = true
            // Let the parent start to consume scroll events.
            target.parent?.requestDisallowInterceptTouchEvent(false)
        }
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int
    ) {
        onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
    }

    /*  Introduced with NestedScrollingParent3. */
    override fun onNestedScroll(
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int,
        consumed: IntArray
    ) {
        onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type)
    }

    /* From ViewGroup */
    override fun onStopNestedScroll(child: View) {
        // The descendant finished scrolling. Clean up!
        setTarget(null)
        parentHelper.onStopNestedScroll(child)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onStopNestedScroll(target: View, type: Int) {
        // The descendant finished scrolling. Clean up!
        setTarget(null)
        parentHelper.onStopNestedScroll(target, type)
    }

    /*  Introduced with NestedScrollingParent2. */
    override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
        return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            false
        } else {
            super.onNestedPreFling(target, velocityX, velocityY)
        }
    }

    /* In ViewGroup for API 21+. */
    override fun onNestedFling(
        target: View,
        velocityX: Float,
        velocityY: Float,
        consumed: Boolean
    ) =
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            false
        } else {
            super.onNestedFling(target, velocityX, velocityY, consumed)
        }

    private fun setTarget(target: View?) {
        nestedScrollTarget = target
        nestedScrollTargetWasUnableToScroll = false
    }
}

Now for the caveat: The preceding code has been lightly (every so lightly) tested. The RecyclerView environment is complex and the introduction of nested scrolling to RecyclerView as a parent of that scrolling makes it even more complex and prone to error. Nonetheless, it is an interesting study IMO.

Upvotes: 11

Zain
Zain

Reputation: 40908

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

Upvotes: 12

Shweta Chauhan
Shweta Chauhan

Reputation: 6981

Check this Github Sample:

enter image description here

  1. Extend BaseRecyclerAdapter(which you get whole code in github) in recycler adapter and implement below methods.

    class ListAdapter : BaseRecyclerAdapter<ListDataModel, ChildDataModel>() {
    
        // Pass parent and child model in BaseRecyclerAdapter
    
        override fun getLayoutIdForType(): Int = R.layout.item_parent  // Provide parent recycler item id
    
        override fun getLayoutIdForChild(): Int = R.layout.item_child  // Provide child recycler item id
    
        // Click event fot parent recycler item
        override fun onParentItemClick(triple: Triple<Int, Any, View>, viewDataBinding: ViewDataBinding) {
            val data = triple.second as ListDataModel // Here you can get parent item data for clicked item
            val position = triple.first // Get position of clicked item
            val view = triple.third // Get view of clicked item
    
            when (view.id) {
                R.id.text_movie_year -> {
                    // Call this function where you want to expand collapse childView
                    expandCollapse(triple.first, viewDataBinding)
                }
            }
        }
    
        // Click event for child rectycler item
        override fun onChildItemClicked(triple: Triple<Int, Any, View>, parentIndex: Int) {
        val data = triple.second as ChildDataModel // Here you can get child item data for clicked item
        val position = triple.first // Get position of clicked item
        val view = triple.third // Get view of clicked item
    
            when (view.id) {
                R.id.img_download_movie -> {
                    // Here you can perform your action
                }
            }
        }
    }
    

Upvotes: 0

rahul.taicho
rahul.taicho

Reputation: 1379

Here's an extension to Zain's sample that avoids any custom scroll logic and chooses a combination of NestedScrollView & RecyclerView

Github Link

The key difference in approach here is to let the NestedScrollView handle the complex scroll computation around when to intercept/dispatch scroll events to the nested layouts.

I've added a LinearLayout container for the nested recyclers with a weight of 1 so they get the uniform height.

fwiw, we can also share view pools across recycler views for a better performance using RecyclerView.setRecycledViewPool()

PS: A definite hit on performance with this solution is that there will be m * n views loaded upon setup where: m - number of rows, n - visible views for the inner recycler.

Hence, for a large value of m, this won't scale very well.

Upvotes: 1

Related Questions