查看原文
其他

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

鸿洋
2024-08-30

The following article is from Android补给站 Author Mr_万能胶

本文作者


作者:Mr_万能胶

链接:

https://juejin.cn/post/7394463308379045951

本文由作者授权发布。


注意标题中的我,指的是本文作者Mr_万能胶,大家可以去掘金瞻仰。


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

1首先,什么问题


我从 ViewPager2 这个组件还在 alpha 阶段的时候就已经开始使用了,一直以来它都存在一个问题,就是会忽略开发者设置的 overScrollMode 属性,不管你在 xml 还是在代码里设置,都不好使。什么是 overScrollMode 属性?看下图:
这个图是我在网上找的,不算准确,但开发者一看就能明白什么意思。在 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 初始化的问题,于是跑去代码里再设置一次,会发现同样无效。

2Google,你做了啥


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

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

https://github.com/material-components/material-components-android/issues/459

https://issuetracker.google.com/issues?q=158234055


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

3Read the FXXKING SOURCE CODE


要找到这个问题的本质原因,肯定还是得看代码。
既然是 ViewPager 是好的,而 ViewPager2不行,我们没理由不去看一下前者的代码:
<androidx.viewpager.widget.ViewPager
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/pager"
    android:layout_width="match_pare再@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();
    }
nt"

    android:layout_height="match_parent"
    android:overScrollMode="never"/>
可以清楚地看到,在 ViewPager 维持了2个EdgeEffect 对象,分别对应左右的 OverScroll 效果,在 draw() 方法,会根据获取到的 getOverScrollMode() 来决定要不要绘制。
那么 ViewPager2 是怎么做的?打开它的类,搜索 getOverScrollMode(),居然是 0 results。
我们都知道 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);
}


4社区是如何建议的


在知道了原理之后,社区已经有一些开发者给出了 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 外面再套一层,这个方法就会失效。

5我看不下去了


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

https://android-review.googlesource.com/c/platform/frameworks/support/+/3184300/7/viewpager2/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java

我的思路也很简单,分两步:
  1. ViewPager2 在初始化的时候,维护了一个 initialize() 方法,在这个方法里去初始化了 RecyclerView,并将其 add 到了自己的 ViewGroup,因此,我们需要在这一步开始就关心一下 overScrollMode,并且透传给 RecyclerView 设下去。
  2. ViewPager2 必须重写 setOverScrollMode(int overScrollMode) 方法,这确保了开发者在手动在代码里调方法设置的时候也能生效。不要忘记 super.setOverScrollMode(overScrollMode);,这确保了你不用自己维护 android.view.View#mOverScrollMode,从而能确保 android.view.View#getOverScrollMode() 的返回值正确。

6单元测试啊,单元测试

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

https://juejin.cn/post/7323399314549145600


同样,androidx 项目也遍布着大量的单元测试。如果你也想给 androidx 做贡献,只改源码,不修改单元测试用例,Google 大概率是不会认可的。
由于是新增了对 overScrollMode 属性的支持,我不希望后续的维护者在修改的时候把这块改坏(regression),因此我必须使用单元测试来保证这块的基本正常。
在我新增的单元测试用例里,我主要保证了这两件事:
  1. 确保开发者从 xml 初始化,和从代码初始化 ViewPager2 的时候,设置的 overScrollMode 能被正确读取,且设置下去。
  2. 确保 ViewPager2 的 overScrollMode 与内部的 RecyclerView2 的 overScrollMode 保持同步,这样就能确保设置是生效的。

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

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:viewpager2/viewpager2/src/androidTest/java/androidx/viewpager2/widget/OverScrollModeTest.java
7结果还是令人满意的


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

8简单总结


这个 bug 被扔进了 backlog 将近5年,现在这个坑总算被填上了,我自己很开心,开发者后面更新版本后发现这个属性能用了肯定也会很开心,可能这就是开源的乐趣所在吧。
androidx 从最开始提出到现在,其实一直都是开源,并且鼓励开发者贡献的。希望各位小伙伴平时发现问题,分析问题,解决问题之后,都可以慷慨将自己的方案提交给 Google,这除了能帮到全球数以万计的开发者之外,自己也能获得一份满满的成就感,何乐而不为呢?


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

Kotlin新特性:Context receivers将被废弃
一文看懂DecorView的一生
LeakCanary 你真的了解么?看看这些高级用法



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存