嵌套在垂直 RecyclerView 中的垂直 RecyclerView

drm*_*wer 4 android android-recyclerview

我花了几个小时/几天来阅读这个主题,但仍然找不到有用的东西。我试图RecyclerView在另一个垂直滚动的行中放置一个固定高度的垂直滚动RecyclerView

大部分建议都是“将垂直滚动RecyclerView放入另一个垂直滚动中是一种犯罪行为RecyclerView”......但我不明白为什么这如此糟糕。

事实上,行为几乎与 StackOverflow 上的许多页面完全相同(例如,这个……实际上这个问题,至少在移动设备上查看时),其中代码部分是固定的(或最大) 高度和垂直滚动,并且包含在本身垂直滚动的页面中。发生的情况是,当焦点在代码部分时,滚动发生在该部分内,当它到达该部分滚动范围的上/下端时,则滚动发生在外页内。这是很自然的,不是邪恶的。

这是我的 recycler_view_row_outer.xml(外层中的一行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>
Run Code Online (Sandbox Code Playgroud)

这是我的recycler_view_row_inner.xml(内部的一行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>
Run Code Online (Sandbox Code Playgroud)

有了上面的内部布局,我尝试按照这篇文章中提出的方法来创建一个RecyclerView具有固定/最大高度的内部......但它不起作用。

我膨胀内部布局(添加到list_container/containerView在外部布局中)并设置我的内部recyclerView如下:

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
Run Code Online (Sandbox Code Playgroud)

但这一切都是创建一个固定高度的内行,它不会在外行滚动......内行的溢出内容只是被切断,我无法到达它的下部,因为它只是滚动外行排。

知道如何进行这项工作吗?

Che*_*amp 9

这是一种使用NestedScrollingParent3接口改造RecyclerView的方法。NestedRecyclerView类用作外部RecyclerView 库存RecyclerView用作内部项目视图。

以下代码基于Widgetlabs的代码 该代码已根据 MIT 许可证获得许可。下面提供的代码已对原始代码进行了大量修改。

操作 当外层RecyclerView被触摸时,它可以按预期上下滚动,并且内层RecyclerView被拖动。

当触摸内部RecyclerView时,它可以上下滚动,而不影响外部RecyclerView。当内部RecyclerView向下或向上达到最大范围时,外部RecyclerView开始滚动。一旦外部RecyclerView开始滚动,它就捕获了手势,直到另一个“向下”事件才会释放它。这与RecyclerView在 NestedScrollView 中的行为方式不同,在NestedScrollView中,外部RecyclerView滚动,但内部RecyclerView在滚动方向更改时将恢复使用滚动手势。本质上,内部RecyclerView永远不会放弃控制,而在这里它却这样做了。

以下视频演示了NestedRecyclerView的操作。在视频中,内部RecyclerView有白色边框以及橙色和红色行。其他都是外部RecyclerView

在此输入图像描述

嵌套RecyclerView

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
    }
}
Run Code Online (Sandbox Code Playgroud)

现在需要注意的是:前面的代码已经过轻微(非常轻微)的测试。RecyclerView环境很复杂,并且将嵌套滚动引入RecyclerView作为该滚动的级,使其变得更加复杂并且容易出错。尽管如此,在我看来,这是一项有趣的研究。


Zai*_*ain 7

方法一:使用两个嵌套的 RecyclerViews

Github 示例

优点:

  • 视图被回收(即良好的性能)
  • 半无缝滚动(更新 3 和 4 后)

缺点:

  • 当到达内部远端项目时,在从内部滚动到外部滚动的过渡期间以编程方式传播的滚动不像手势那样平滑/自然。
  • 复杂的代码。

好吧,我不会解决垂直嵌套的性能问题RecyclerViews;但请注意:

  • 内部RecyclerView可能失去了回收视图的能力;因为外部 recyclerView 的显示行应该完全加载它们的项目。(幸运的是,根据下面的更新 1,这不是一个正确的假设)
  • 我在ViewHoldernot in 中声明了一个单独的适配器实例,onBindViewHolder以便在RecyclerView每次回收视图时不为内部创建一个新的适配器实例来获得更好的性能。

演示应用程序将一年中的月份表示为外部RecyclerView,每个月的天数表示为内部RecyclerView

每次滚动时,外部RecyclerView寄存器OnScrollListener都会对内部 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);
        }
    });
Run Code Online (Sandbox Code Playgroud)

在外部适配器中:

    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);
    }
Run Code Online (Sandbox Code Playgroud)

如果未显示相关项目,则我们决定禁用外部 RV 滚动。这由具有回调的侦听器处理,该回调接受传递给LinearLayoutManager外部 RV的自定义类的布尔值。

同样,为了重新启用外部 RV 的滚动:内部RecyclerView注册OnScrollListener以检查内部第一个/最后一个项目是否显示。

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);
        }
    }
});
Run Code Online (Sandbox Code Playgroud)

尽管如此,由于禁用/启用滚动,还是会出现故障;我们不能将滚动顺序传递给另一个 RV,直到下一次滚动。这是通过反转初始外部 RV 滚动值来稍微操纵的;并使用任意滚动值到内部currentRV.smoothScrollBy(0, 10 * initialScroll)。我希望是否有人可以提出任何其他替代方案。

更新 1

  • 内部RecyclerView可能失去了回收视图的能力;因为外部 recyclerView 的显示行应该完全加载它们的项目。

值得庆幸的是,这不是正确的假设,并且视图是通过使用List保存当前加载项目的 跟踪内部适配器中项目的回收列表来回收的:

假设某个月有 1000 天“2 月,因为它总是被压制:)”,然后向上/向下滚动以注意加载的列表并确保它onViewRecycled()被调用。

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

}
Run Code Online (Sandbox Code Playgroud)

日志:

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]
Run Code Online (Sandbox Code Playgroud)

更新 2

由于禁用/启用滚动,仍然存在故障;我们不能将滚动顺序传递给另一个 RV,直到下一次滚动。这是通过反转初始外部 RV 滚动值来稍微操纵的;并使用任意滚动值到内部currentRV.smoothScrollBy(0, 10 * initialScroll)。我希望是否有人可以提出任何其他替代方案。

  • 使用更大的任意值(如 30)使语法滚动看起来更流畅 >> currentRV.smoothScrollBy(0, 30 * initialScroll)

  • 并且在不反转滚动的情况下滚动外滚动,使其在滚动的同一方向上也看起来更自然:

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);
}
Run Code Online (Sandbox Code Playgroud)

更新 3

问题:在从外部到内部的过渡期间出现故障,RecyclerView因为onScroll()在决定我们是否可以滚动内部之前,外部的 被调用。

通过使用OnTouchListener外部 RecyclerView 并覆盖onTouch()并返回true以消耗事件(这样onScrolled()不会被调用),直到我们决定内部可以进行滚动。

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;
});
Run Code Online (Sandbox Code Playgroud)

更新 4

  • RecyclerView每当内部 RV 滚动到其顶部或底部边缘时,启用从内部到外部的滚动过渡,以便它以成比例的速度继续滚动到外部 RV。

滚动速度的灵感来自这篇文章。通过在触摸的内部 RV's OnTouchListener& 中应用第一个/最后一个项目检查OnScrollListener,并在全新的触摸事件中重置内容,即MotionEvent.ACTION_DOWN

  • 在内部和外部RecyclerViews 中禁用过度滚动模式

预览:

方法二:在 NestedScrollView 中包装外部 RecyclerView

Github 示例

a 的嵌套滚动的主要问题RecyclerView是它没有实现由;实现的NestedScrollingParent3接口NestedScrollView。因此RecyclerView无法处理子视图的嵌套滚动。因此,尝试NestedScrollView通过将外部包裹RecyclerView在 a 中来使用 a 进行补偿NestedScrollView,并禁用外部的滚动RecyclerView

优点:

  • 简单的代码(您根本不必操作内部/外部滚动)
  • 没有故障
  • 无缝滚动

缺点:

  • 性能低下,因为外部视图RecyclerView不会被回收,因此它们必须在显示在屏幕上之前全部加载。

原因:由于NestedScrollView>>检查(1)(2)讨论回收问题的问题的性质:

方法三:使用 ViewPager2 作为外层 RecyclerView

Github 示例

使用 aViewPager2在内部使用 aRecyclerView解决了回收视图的问题,但一次只能显示一页(一个外行)。

优点:

  • 使用时没有故障和无缝滚动 NestedScrollableHost
  • 视图被回收作为有一个内部RecyclerViewViewPager2

缺点:

  • 每页仅显示一个项目

所以我们可能通过研究以下任一方法来解决这个问题:

  • 如何每页显示多个视图
  • 如何 wrap_content 一个页面