5年了,ViewPager2 终于支持 overScrollMode,没错,我干的。

4,307 阅读6分钟

这两周给 androidx 做了一点微小的贡献,可算是把多年来的一个小坑给填上了,今天有时间就写一篇文章,详细记录一下整个过程。

首先,什么问题

我从 ViewPager2 这个组件还在 alpha 阶段的时候就已经开始使用了,一直以来它都存在一个问题,就是会忽略开发者设置的 overScrollMode 属性,不管你在 xml 还是在代码里设置,都不好使。什么是 overScrollMode 属性?看下图:

image.png

这个图是我在网上找的,不算准确,但开发者一看就能明白什么意思。在 Android 的控件里,无论是上下滑动的列表,还是左右滑动的 ViewPager,当你滑到头之后,再次同方向滑动,就会出现一个水波纹一样的效果(如果运用了 Material Design,则是果冻效应一样的效果),这个效果被称为 ripple,用来告诉用户列表已经到头了,没有了,到底了,你该往回滑了。

这个效果的本意是好的,我个人也非常喜欢,但是不知道为什么,国内的设计师们似乎都不太喜欢。4年来,我经手了无数个项目,就没有哪个项目设计师让把这个效果留着的,统统都要求去掉。

在 ViewPager2 出来之前,大家都在用 ViewPager,要去掉这个效果非常简单,只需要多设置一行属性即可:

<androidx.viewpager.widget.ViewPager
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/pager"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:overScrollMode="never"/>

而当你迁移到 ViewPager2 之后,如果同样的方式设置这个属性,你会发现这个属性失效了。有同学会怀疑可能是 xml 初始化的问题,于是跑去代码里再设置一次,会发现同样无效。

Google,你做了啥

说起来也是哭笑不得,这个问题最早在社区被提出,已经是5年前了。

彼时 androidx 还在 github 积极开发,有开发者发现了这个问题,先提了 issue(至今还是 Open 状态),而后过了大半年没人管,大家觉得可能这样还不够引起重视,于是有人直接提到了 Issue tacker,这个 Google 内部拿来跟踪 bug 的。

然后一恍就是5年,5年了,没人管。

image.png

Read the FXXKING SOURCE CODE

要找到这个问题的本质原因,肯定还是得看代码。

既然是 ViewPager 是好的,而 ViewPager2不行,我们没理由不去看一下前者的代码:

@NonNull
public EdgeEffect mLeftEdge;
@NonNull
public EdgeEffect mRightEdge;

@Override
public void draw(@NonNull Canvas canvas) {
    super.draw(canvas);
    boolean needsInvalidate = false;

    final int overScrollMode = getOverScrollMode();
    if (overScrollMode == View.OVER_SCROLL_ALWAYS
            || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS
                    && mAdapter != null && mAdapter.getCount() > 1)) {
        if (!mLeftEdge.isFinished()) {
            // ...
        }
        if (!mRightEdge.isFinished()) {
            // ...
        }
    } else {
        mLeftEdge.finish();
        mRightEdge.finish();
    }

可以清楚地看到,在 ViewPager 维持了2个EdgeEffect 对象,分别对应左右的 OverScroll 效果,在 draw() 方法,会根据获取到的 getOverScrollMode() 来决定要不要绘制。

那么 ViewPager2 是怎么做的?打开它的类,搜索 getOverScrollMode(),居然是 0 results。

image.png

我们都知道 ViewPager2 继承的是 ViewGroup,本质是靠内部维护的一个 RecyclerView 来实现的,而 RecyclerView 是对 overScrollMode 有处理的,如果你不想在一个列表上见到 ripple 效果,只要对应设置即可,这部分逻辑在 RecyclerView 源码的 androidx.recyclerview.widget.RecyclerView.ViewFlinger 部分:

// Based on movement, we may want to trigger the hiding of existing over scroll
// glows.
if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
    considerReleasingGlowsOnScroll(unconsumedX, unconsumedY);
}

社区是如何建议的

在知道了原理之后,社区已经有一些开发者给出了 workaround,比如下面这种:

View child = viewPager2.getChildAt(0);
if (child instanceof RecyclerView) {
    child.setOverScrollMode(View.OVER_SCROLL_NEVER);
}

一目了然,既然已经知道了原理,我们只要获取到 ViewPager2 的第一个子 View,那必定是内部的这个 RecyclerView,然后再对它调用 setOverScrollMode(View.OVER_SCROLL_NEVER) 即可。

或者,如果你像我一样喜欢 Kotlin,我会直接给 ViewPager2 扩展一个方法,这个方法已经被我在无数个项目之间拷来拷去了:

fun ViewPager2.setOverScrollModeExt(overScrollMode: Int) {
    val view = getChildAt(0)
    if (view is RecyclerView) {
        (view as RecyclerView).overScrollMode = overScrollMode
    }
}

这个解决方案一定程度还是比较稳妥的,但有一个很大风险点就是它假定了一个前提,那就是:

“ViewPager2 的第一个子 View 一定是 RecyclerView”

如果哪天 Google 换了设计或者改了方案,在 RecyclerView 外面再套一层,这个方法就会失效。

我看不下去了

来到今年下半年,由于项目的关系又用到了这里,我实在看不下去了,翻出来前面所有这些 bug,给 Google 提了一个 CL(Google 把每一个 Gerrit 上的提交称为 CL,即 Change Line):

android-review.googlesource.com/c/platform/…

我的思路也很简单,分两步:

  1. ViewPager2 在初始化的时候,维护了一个 initialize() 方法,在这个方法里去初始化了 RecyclerView,并将其 add 到了自己的 ViewGroup,因此,我们需要在这一步开始就关心一下 overScrollMode,并且透传给 RecyclerView 设下去。

  2. ViewPager2 必须重写 setOverScrollMode(int overScrollMode) 方法,这确保了开发者在手动在代码里调方法设置的时候也能生效。不要忘记 super.setOverScrollMode(overScrollMode);,这确保了你不用自己维护 android.view.View#mOverScrollMode,从而能确保 android.view.View#getOverScrollMode() 的返回值正确。

单元测试啊,单元测试

在大约半年前,我写了一篇关于单元测试的文章,向大家详细介绍了单元测试在 Google Android 项目中的重要性,如果你有兴趣,可以再次阅读:

juejin.cn/post/732339…

同样,androidx 项目也遍布着大量的单元测试。如果你也想给 androidx 做贡献,只改源码,不修改单元测试用例,Google 大概率是不会认可的。

由于是新增了对 overScrollMode 属性的支持,我不希望后续的维护者在修改的时候把这块改坏(regression),因此我必须使用单元测试来保证这块的基本正常。

在我新增的单元测试用例里,我主要保证了这两件事:

  1. 确保开发者从 xml 初始化,和从代码初始化 ViewPager2 的时候,设置的 overScrollMode 能被正确读取,且设置下去。
  2. 确保 ViewPager2 的 overScrollMode 与内部的 RecyclerView2 的 overScrollMode 保持同步,这样就能确保设置是生效的。

大块的代码就不贴了,如果大家有兴趣,可以直接这里阅读。

结果还是令人满意的

从时间线上可以看出,只要代码质量过硬,符合贡献标准,其实 Google 的 androidx 团队成员还是很乐于跟进的,我在周五下班前提交了代码,经过了一系列 review、CI,和一个愉快的周末,这笔提交已经在周二 Merge。按照以往的节奏,大概率在3个月之后的 androidx 新版本里面就可以体现。

简单总结

这个 bug 被扔进了 backlog 将近5年,现在这个坑总算被填上了,我自己很开心,开发者后面更新版本后发现这个属性能用了肯定也会很开心,可能这就是开源的乐趣所在吧。

androidx 从最开始提出到现在,其实一直都是开源,并且鼓励开发者贡献的。希望各位小伙伴平时发现问题,分析问题,解决问题之后,都可以慷慨将自己的方案提交给 Google,这除了能帮到全球数以万计的开发者之外,自己也能获得一份满满的成就感,何乐而不为呢?