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的滑动呢?方案有二:
- 自定义
LayoutManager,重写其滑动方法:
class NonScrollableLinearLayoutManager(context: Context) : LinearLayoutManager(context) {
override fun canScrollVertically(): Boolean {
return false
}
}
- 消费触摸事件:
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.0f,computeVerticalScrollOffset()方法在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,验证一下:
可以看到,子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!🫡