嵌套在垂直 RecyclerView 中的垂直 RecyclerView

8

我已经花费了数小时/天阅读有关此主题的内容,但仍然找不到有效的解决方案。我试图在另一个垂直滚动的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>

这是我的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>

在上述内部布局中,我尝试遵循这篇文章中的方法,创建一个具有固定/最大高度的内部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

但所有这些只是创建了一个固定高度的内部行,在外部行内不会滚动...内部行的溢出内容被截断,我无法到达其底部,因为它只会滚动外部行。

有什么方法可以使这个工作?


你是否评估过使用嵌套滚动视图和回收视图的解决方案?这理想情况下应该可以轻松处理嵌套滚动行为。 - rahul.taicho
5个回答

12

方案一:使用两个嵌套的RecyclerView

Github示例

优点:

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

缺点:

  • 当到达内部最远端项目时,从内部滚动到外部滚动的过渡中程序传播的滚动不太平滑/自然,像手势那样。
  • 代码复杂。

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

  • 内部RecyclerView可能失去回收视图的能力;因为外部RecyclerView的显示行应该完全加载其项目。(幸运的是,根据下面的UPDATE 1,这不是正确的假设)
  • 我在ViewHolder中声明了一个单独的适配器实例,而不是在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);
        }
    });

在外部适配器中:

    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);
    }

如果相关项目未显示,则我们决定禁用外部RV滚动。这由一个监听器处理,具有回调函数,接受传递给外部RV的自定义LinearLayoutManager类的布尔值。
同样地,为了重新启用外部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);
        }
    }
});

仍然存在一些问题,因为禁用/启用滚动;我们无法将滚动顺序传递给其他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

}

日志:

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]

更新2

仍然存在一些问题,因为禁用/启用滚动;我们无法将滚动顺序传递给另一个RV,直到下一次滚动。这通过反转初始外部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);
}

更新3

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

解决方法:通过向外部RecyclerView使用OnTouchListener,并覆盖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;
});

更新4

  • 当内部RecyclerView滚动到顶部或底部边缘时,启用从内部到外部的滚动转换,使其以比例速度继续滚动到外部RV。

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

  • 禁用内部和外部RecyclerView的超出滚动模式

预览:

方案2:将外部RecyclerView包装在NestedScrollView中

Github示例

RecyclerView的主要问题是它没有实现NestedScrollView实现的NestedScrollingParent3接口;因此,RecyclerView无法处理子视图的嵌套滚动。因此,通过将外部RecyclerView包装在NestedScrollView中,并禁用外部RecyclerView的滚动来尝试弥补这一点。

优点:

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

缺点:

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

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

方案3:使用ViewPager2作为外部RecyclerView

Github示例

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

优点:

  • 使用NestedScrollableHost时没有故障和无缝滚动
  • 由于ViewPager2内部有一个RecyclerView,因此视图被回收

缺点:

  • 每页仅显示一个项目

因此,我们可能需要研究以下内容:

  • 如何在一页上显示多个视图
  • 如何包装页面的内容

这确实看起来很有趣。我会查看示例。但我注意到的一件事是第一个警告:“内部RecyclerView可能会失去回收视图的功能;因为外部RecyclerView显示的行应该完全加载它们的项”。这对我来说是一个很大的负面影响,因为其中一些内部RV有很多行(这就是我从普通ViewGroup迁移到RV的部分原因),所以我真的需要避免一次性添加所有项。有没有办法避免这种情况呢? - drmrbrewer
1
@drmrbrewer 感谢您,这并不是正确的假设。请查看答案中的更新部分。 - Zain
1
我尝试了示例(在您的UPDATE 2更改后)。 它非常接近。 它仍然感觉有点故障,因为即使在滚动内部RV时(不在任何极端情况下),外部RV有时也会滚动一点点。 此外,当实际触摸/滚动月份名称时,似乎无法滚动外部RV ... 当这些“部分”关闭(内部RV隐藏)时,这将特别重要,以便主要显示外部RV(月份行)。 我很惊讶需要付出如此多的努力才能接近实现应该自动发生的事情。 - drmrbrewer
1
@drmrbrewer 请查看第一种方法中的 UPDATE 3,以及第二种和第三种方法。 - Zain
1
谢谢。你的回答对我很有帮助,我相信其他人也会发现它很有用。对我来说,第二种方法最吸引人,因为它相对简单(只需在RV内部包装NSV)。在我的情况下,外部RV相对较短且非常简单(实际上只是节标题,非常类似于您的月份名称,但更多),因此从一开始就必须布置所有行并不是一个巨大的负担(如果它允许我保留RV模型的其他优点)。 - drmrbrewer
@drmrbrewer 感谢您的关注,很高兴听到方案2更接近您的想法;现在刚刚更新了方案1(更新4),并稍微调整了缺点。感谢您查看。 - Zain

9
这里提供一种使用NestedScrollingParent3接口来改进RecyclerView 的方法。外层RecyclerView 使用NestedRecyclerView 类,内部项目视图使用标准RecyclerView
下面的代码基于来自Widgetlabs这里 的代码,该代码在MIT许可下获得许可。下面呈现的代码已经从原始代码进行了大量修改。 操作 当触摸外层RecyclerView 时,可以像预期的那样向上和向下滚动,并将内部RecyclerView 拖动。
当触摸内部RecyclerView 时,可以向上和向下滚动,而不会影响外部RecyclerView 。当内部RecyclerView 到达其最高或最低点时,外部RecyclerView 开始滚动。一旦外部RecyclerView 开始滚动,它就捕获了手势,并且只会在另一个“down”事件发生时释放它。这与RecyclerView NestedScrollView 中的行为不同,在其中外部RecyclerView 滚动,但内部RecyclerView 会在滚动方向改变时恢复消耗滚动手势。实质上,内部RecyclerView 在这里从未放弃控制。
以下视频演示了NestedRecyclerView 的操作。在视频中,内部RecyclerView 有一个白色边框和橙色和红色行。其他都是外部的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
    }
}

现在需要注意的是:上述代码已经进行了轻微(非常轻微)的测试。RecyclerView 环境十分复杂,将嵌套滚动引入到作为滚动父级的 RecyclerView 中会让它变得更加复杂和容易出错。不过,我认为这是一个有趣的研究。


有趣的方法,谢谢。外层RecyclerView是一个RecyclerView还是现在成为了一个ScrollView?我特别喜欢使用RecyclerView,因为它可以与实现FilterableRecyclerView.Adapter相关联。 - drmrbrewer
@drmrbrewer 外部视图是一个嵌套的RecyclerView,如答案所示。内部可滚动部分只是普通的RecyclerView。因此,这是一个真正的RecyclerView嵌套。 - Cheticamp
@drmrbrewer 你应该可以用NestedRecyclerView替换外部的RecyclerView来查看滚动行为。如果不是这样,请告诉我,我很感兴趣。 - Cheticamp
大多数情况下,这个功能非常好用。唯一遇到问题的时候是如果有一个附加的ItemTouchHelper。在这种情况下,对嵌套视图进行快速滑动会被解释为长按该项。 - TheWanderer
经过更多的尝试,我发现了一个解决方法,即在RecyclerView中禁用触摸助手,如果super.onNestedFling()的返回值为false,在onNestedScroll()中,如果增量大于触摸斜率。这不是很好,因为它依赖于直接在RecyclerView上设置变量,但我不确定还有什么其他方法可以修复它。 - TheWanderer

1
这是对Zain示例的扩展,避免了任何自定义滚动逻辑,并选择使用NestedScrollViewRecyclerView的组合。 Github链接 这里的关键差异在于让NestedScrollView处理复杂的滚动计算,以确定何时拦截/分派滚动事件到嵌套布局。
我添加了一个带有权重1的LinearLayout容器,用于嵌套的可回收视图,以便它们获得统一的高度。
顺便说一下,我们还可以使用RecyclerView.setRecycledViewPool()跨回收视图共享视图池,以获得更好的性能。
PS:此解决方案对性能的明显影响是,在设置过程中会加载m * n个视图,其中:m-行数,n-内部可见视图数。
因此,对于较大的m值,这种方法无法很好地扩展。

0

Cheticamp的回答非常适用于你不需要支持拖动的ItemTouchHelper的情况。

对于这种情况,我想出了一个凌乱但却有用的解决方法:

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
            ) {
            }
        })
    }
}

您需要直接访问包含ItemTouchHelper及其回调函数的NestedRecyclerView实例,以便监听和设置不同的状态。我已经对我添加的新属性进行了注释。


0
请查看这个Github示例:

enter image description here

  1. 在recycler adapter中扩展BaseRecyclerAdapter(您可以在github上获取整个代码)并实现以下方法。

    class ListAdapter : BaseRecyclerAdapter<ListDataModel, ChildDataModel>() {
    
        // 在BaseRecyclerAdapter中传递父模型和子模型
    
        override fun getLayoutIdForType(): Int = R.layout.item_parent  // 提供父recycler项id
    
        override fun getLayoutIdForChild(): Int = R.layout.item_child  // 提供子recycler项id
    
        // 点击父recycler项的事件
        override fun onParentItemClick(triple: Triple<Int, Any, View>, viewDataBinding: ViewDataBinding) {
            val data = triple.second as ListDataModel // 在此处,您可以获取所点击项的父项数据
            val position = triple.first // 获取所点击项的位置
            val view = triple.third // 获取所点击项的视图
    
            when (view.id) {
                R.id.text_movie_year -> {
                    // 在需要展开/折叠childView的地方调用此函数
                    expandCollapse(triple.first, viewDataBinding)
                }
            }
        }
    
        // 点击子recycler项的事件
        override fun onChildItemClicked(triple: Triple<Int, Any, View>, parentIndex: Int) {
        val data = triple.second as ChildDataModel // 在此处,您可以获取所点击项的子项数据
        val position = triple.first // 获取所点击项的位置
        val view = triple.third // 获取所点击项的视图
    
            when (view.id) {
                R.id.img_download_movie -> {
                    // 在此处,您可以执行您的操作
                }
            }
        }
    }
    

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接