当我把RecyclerView.LayoutManager.canScrollVertically() 返回 false 后...

2,595 阅读7分钟

RecyclerView是Android开发中用于展示大量数据集的一个灵活且高效的视图组件,提供了强大的数据展示和处理能力。RecyclerView本身不直接负责条目的具体布局方式,而是通过引入LayoutManager负责管理RecyclerView中条目的布局,以定义列表的滚动以及布局形式。这种设计大大提升了RecyclerView的灵活性和可扩展性。开发者可以使用系统提供的几种LayoutManager:

  • LinearLayoutManager:支持垂直和水平的线性布局。
  • GridLayoutManager:支持网格布局。
  • StaggeredGridLayoutManager:支持瀑布流布局,即不规则的网格布局。

一个简单的需求

一个类听书App,上半部分展示书籍的名称、作者,下半部分是播控区域,中间剩余的空间使用RecyclerView展示字幕

UI:我想要一个淡入淡出的效果

按照设计稿做完后UI老师不太满意,觉得字幕的边缘文字被截断,视觉上割裂感严重,所以想增加一个淡入淡出的效果。

恰好,在Android开发中,可以使用requiresFadingEdge属性指定一个视图(如RecyclerView、ScrollView等)的边缘是否应该显示渐变的阴影效果,以指示该视图可以滚动。这个属性主要用于提升用户体验,通过在视图的顶部或底部(或两者)显示一个渐变的阴影,来提示用户该视图的内容是可以滚动的。

在XML布局文件中直接使用requiresFadingEdge属性,并指定想要显示阴影效果的边缘。这个属性有三个值可以选择:

  • none:不显示任何边缘的渐变阴影。
  • horizontal:在视图的左右边缘显示渐变阴影。
  • vertical:在视图的上下边缘显示渐变阴影。

此处,我们设置android:requiresFadingEdge="vertical"以启用渐变阴影效果,然后再通过android:fadingEdgeLength属性来自定义阴影的长度,此处我们设置android:fadingEdgeLength="20dp"。完整代码如下:

<androidx.recyclerview.widget.RecyclerView
  android:layout_width="match_parent"
  android:layout_height="0dp"
  android:requiresFadingEdge="vertical"
  android:fadingEdgeLength="20dp"
  app:layout_constraintTop_toTopOf="parent"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintBottom_toBottomOf="parent"
  android:layout_marginBottom="160dp"
  android:layout_marginTop="150dp"/>

调整完成后run一下,可以看到已经实现了“淡入淡出”的效果。

产品:可不可以禁止用户滑动「RecyclerView」?

产品体验时,希望 禁止用户通过滑动RecycerView查看字幕 ,我们需要做的就是禁用掉RecyclerView的滑动。如何禁用掉RecyclerVIew的滑动呢?方案有二:

  1. 自定义LayoutManager,重写其滑动方法:
class NonScrollableLinearLayoutManager(context: Context) : LinearLayoutManager(context) {

    override fun canScrollVertically(): Boolean {
        return false
    }

}
  1. 消费触摸事件:
recyclerView.setOnTouchListener(object : OnTouchListener {
    override fun onTouch(v: View, event: MotionEvent): Boolean {
        // 返回true表示触摸事件被消费了,RecyclerView将不会处理滑动事件
        return true
    }
})

相比于第一种方式,第二种方法会禁用所有的触摸事件,包括点击事件。需求里RecyclerView是有点击行为的交互的,所以我们选用第一种方法,自定义NonScrollableLinearLayoutManager 继承自 LinearLayoutManager, 并重写canScrollVertically()方法返回false来禁用RecyclerView的滑动。

「意外」bug?

原本以为这个需求到这里就应该画上一个句号了,结果才是好戏的开始,一堆意想不到的问题接二连三的出现了:

淡入淡出的效果呢?

最后的UI走查阶段,UI老师反馈说“淡入淡出”的效果又没有了。怎么回事,谁把我淡入淡出的代码给误删了么?我那么一大段代码呢?翻到对应的代码一看,不对啊,代码在的啊。

人生最痛苦的事情莫过于此,代码还在,效果却没了。

还是来看看问题出在哪里吧:淡入淡出的效果失效前后,我们只是禁用了RecyclerView的滑动,所以这个调整嫌疑最大。为了验证我们的猜想,我们先让RecyclerView恢复成可滑动,果不其然,淡入淡出的效果又生效了。看来问题就出在这里了。

作为View的通用效果,淡入淡出的实现放在了View类里,具体代码在android.view.View#draw(android.graphics.Canvas)中:

public void draw(@NonNull Canvas canvas) {
    final int privateFlags = mPrivateFlags;
    mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

    /*
         * 绘制主要包含以下几个步骤:
         *
         *      1. 绘制背景
         *      2. 如果有必要,保存画布以准备绘制“淡入淡出”的效果
         *      3. 绘制View的内容
         *      4. 绘制子View
         *      5. 如果有必要,准备绘制“淡入淡出”的效果
         *      6. 绘制装饰(例如滚动条)
         *      7. 如有必要,绘制默认的焦点高亮
         */

    // 省略不相关代码...
    final int viewFlags = mViewFlags;
    // 设置android:requiresFadingEdge="vertical",verticalEdges值为true
    boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
    boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
    if (!verticalEdges && !horizontalEdges) {
        // 如果不需要绘制 纵向 and 横向 的淡入淡出效果,跳过步骤2和步骤5
        // 此处代码省略...
        return;
    }


    boolean drawTop = false;
    boolean drawBottom = false;

    float topFadeStrength = 0.0f;
    float bottomFadeStrength = 0.0f;


    // fadeHeight 即为我们通过 android:fadingEdgeLength 属性指定的淡入淡出效果的高度
    final ScrollabilityCache scrollabilityCache = mScrollCache;
    final float fadeHeight = scrollabilityCache.fadingEdgeLength;
    int length = (int) fadeHeight;

    // 省略不相关代码...

    // 分别计算上下淡入淡出效果的“强度strength”
    if (verticalEdges) {
        topFadeStrength = Math.max(0.0f, Math.min(1.0f, getTopFadingEdgeStrength()));
        drawTop = topFadeStrength * fadeHeight > 1.0f;
        bottomFadeStrength = Math.max(0.0f, Math.min(1.0f, getBottomFadingEdgeStrength()));
        drawBottom = bottomFadeStrength * fadeHeight > 1.0f;
    }


    // 步骤3,绘制View内容
    onDraw(canvas);

    // 步骤4,绘制子View
    dispatchDraw(canvas);

    // 步骤5,绘制淡入淡出效果
    final Paint p = scrollabilityCache.paint;
    final Matrix matrix = scrollabilityCache.matrix;
    final Shader fade = scrollabilityCache.shader;

    // drawBottom为true,绘制底部淡入淡出的效果
    if (drawBottom) {
        matrix.setScale(1, fadeHeight * bottomFadeStrength);
        matrix.postRotate(180);
        matrix.postTranslate(left, bottom);
        fade.setLocalMatrix(matrix);
        p.setShader(fade);
        if (solidColor == 0) {
            canvas.restoreUnclippedLayer(bottomSaveCount, p);
        } else {
            canvas.drawRect(left, bottom - length, right, bottom, p);
        }
    }

    // drawTop为true,绘制顶部淡入淡出的效果
    if (drawTop) {
        matrix.setScale(1, fadeHeight * topFadeStrength);
        matrix.postTranslate(left, top);
        fade.setLocalMatrix(matrix);
        p.setShader(fade);
        if (solidColor == 0) {
            canvas.restoreUnclippedLayer(topSaveCount, p);
        } else {
            canvas.drawRect(left, top, right, top + length, p);
        }
    }
    // 省略不相关代码...
}

以顶部的淡入淡出效果为例,精简不相关的代码后,淡入淡出的效果绘制只和topFadeStrength属性相关,获取逻辑为:topFadeStrength = Math.max(0.0f, Math.min(1.0f, getTopFadingEdgeStrength()))

关键代码就在getTopFadingEdgeStrength(),一起看看:

protected float getTopFadingEdgeStrength() {
    return computeVerticalScrollOffset() > 0 ? 1.0f : 0.0f;
}

代码逻辑很简单,根据computeVerticalScrollOffset()返回值是否大于0 返回 1.0f 或者 0.0fcomputeVerticalScrollOffset()方法在RecyclerView中被重写,我们来看下RecyclerView中的实现:

@Override
public int computeVerticalScrollOffset() {
    if (mLayout == null) {
        return 0;
    }
    return mLayout.canScrollVertically() ? mLayout.computeVerticalScrollOffset(mState) : 0;
}

答案很明了了,因为我们重写了canScrollVertically(),将其返回为false,导致computeVerticalScrollOffset()返回值为0,进而导致getTopFadingEdgeStrength()返回为0,最终导致淡入淡出的效果失效。

  • 问题找到了,怎么解决呢?
  • 最简单的方法就是重写getTopFadingEdgeStrength()方法,返回 1.0f,代码如下:
class FadingEdgeRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {

    override fun getTopFadingEdgeStrength(): Float {
        return 1f
    }

    override fun getBottomFadingEdgeStrength(): Float {
        return 1f
    }
}

这样,淡入淡出的问题也就解决了。

ItemView被截断?

图上我们明显能看到ItemView被截断了...啊嘞嘞,怎么回事?

按照我们对RecyclerView的了解,它的子View的高度的测量模式,应该为MeasureSpec.UNSPECIFIED,这种模式下子View的高度不可能小于内容高度导致文字内容被截断的啊?

关于测量模式的更多知识,可查看往期文章: 「测量流程」到底是怎么一回事?真的需要设计的这么复杂么?

只有一种可能,子View的高度的测量模式不是MeasureSpec.UNSPECIFIED,验证一下: Untitled (1).jpeg 可以看到,子View高度的测量模式值为-2147483648,即为MeasureSpec.AT_MOST,而不是我们印象中的MeasureSpec.UNSPECIFIED

继续追查,我们知道,子View的测量模式是由其父View指定的,直接查看RecyclerView.LayoutManager#getChildMeasureSpec(int, int, int, int, boolean)方法源码:

public static int getChildMeasureSpec(int parentSize, int parentMode, int padding,
                                      int childDimension, boolean canScroll) {
    int size = Math.max(0, parentSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    if (canScroll) {
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            switch (parentMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.EXACTLY:
                    resultSize = size;
                    resultMode = parentMode;
                    break;
                case MeasureSpec.UNSPECIFIED:
                    resultSize = 0;
                    resultMode = MeasureSpec.UNSPECIFIED;
                    break;
            }
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            resultSize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
    } else {
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            resultSize = size;
            resultMode = parentMode;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            resultSize = size;
            if (parentMode == MeasureSpec.AT_MOST || parentMode == MeasureSpec.EXACTLY) {
                // 不能滑动 且 RecyclerView的测量模式为AT_MOST或者 EXACTLY,子View测量模式为AT_MOST
                resultMode = MeasureSpec.AT_MOST;
            } else {
                resultMode = MeasureSpec.UNSPECIFIED;
            }

        }
    }
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

直接就找到了原因:LayoutManager.canScrollVertically() 返回true后,结合RecyclerView自身的测量模式(不为UNSPECIFIED),子View的高度的测量模式为AT_MOST

  • 如何解决ItemView被截断的问题呢?
  • 最简单的方式就是自定义View,重写onMeasure方法,将对应的高度测量模式重置为UNSPECIFIED,代码如下:
class UnspecifiedHeightTextView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : androidx.appcompat.widget.AppCompatTextView(context, attrs) {
    
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val unspecifiedHeightSpec = MeasureSpec.makeMeasureSpec(
            MeasureSpec.getSize(heightMeasureSpec),
            MeasureSpec.UNSPECIFIED
        )
        super.onMeasure(widthMeasureSpec, unspecifiedHeightSpec)
    }
}

重新运行一下,问题解决:

撒花🎉,完结~

总结

只是修改了RecyclerView.LayoutManager.canScrollVertically()的返回值,引出了一系列的问题,在特定条件下,小的变化都可能会导致不可预测和非线性的结果。

我是沈剑心,侠肝义胆沈剑心,我们下次再见~

记得要对代码保持敬畏之心,salute!🫡