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?
Github sample
Pros:
Cons:
Well, I won't address the performance issues of vertically nested RecyclerViews
; But notice that:
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)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:
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
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
RecyclerView
sPreview:
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:
Cons:
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:
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:
NestedScrollableHost
RecyclerView
in ViewPager2
Cons:
So we probably tackle this by researching either:
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With