自个儿写Android的下拉刷新/上拉加载控件 (续)

本文算是对之前的一篇博文《自个儿写Android的下拉刷新/上拉加载控件》的续章,如果有兴趣了解更多的朋友可以先看一看之前的这篇博客。

事实上之所以会有之前的那篇博文的出现,是起因于前段时间自己在写一个练手的App时很快就遇到这种需求。其实我们可以发现类似这样下拉刷新、上拉加载的功能正在变得越来越普遍,可以说如今基本上绝大多数的应用里面都会使用到。当然,随着Android的发展,已经有不少现成的可以实现这种需求的“轮子”供我们使用了。

但转过头想一下想,既然本来就是自己练手之作。为什么还要去用别人的“轮子”呢?何不自己也试着造一造“轮子”?其实以上拉刷新、下拉加载这种需求来说,以前基本上都是应用在ListView上面的。所以回想一下:可以说前两年PullToRefreshListView还依然是很多应用里都会使用到的开源库。但是随着Android的飞速发展,比如RecyclerView的出现等等。这时候,像PullToRefreshListView这种对于ListView所做的扩展,就显得不够了。

所以,我首先很快决定:自己要定义的是一个可以通用这种功能的ViewGroup,而不是针对某种特定的View来做扩展。(当然了,这两种做法显然各自有利也有弊,关键还是看实际的需求采用哪种方式更合适以及自己的取舍了)至于接下来的工作,自然就是整理思路,并逐步加以实现了。

当最后完成后,当然免不了记录一下这个过程中的思路、收获等来进行巩固。于是,就有了之前的博文。在这之后,有收到了不少朋友的鼓励;当然也有朋友提出了很多不足的地方和很多有用的建议。衷心感谢!!!其实因为水平、时间以及精力等缘故,对于最初的实现方式,之后我自己有空时再回过头去看的时候,也发现很多地方不太满意,很多地方可以改进。也正是基于这些原因,就有了之后的优化改良工作。于是,最终对应的又有了这篇博客的诞生,在这里总结一下这次优化的思路以及收获。


一、上拉、下拉动画效果的修改

实际上,在之前的实现里,自己选择了如下的下拉刷新以及上拉加载的动画效果:

这是因为之前觉着反正也是自己写着爽爽,不如就搞点比较有意思的Loading效果。但后来有朋友告诉我,如果我感兴趣,想要使用一下你的控件,那么这种动画却有点不太实用。自己想想也是,比如假设我打算把自己的应用发布到应用市场,那这种效果确实有点不太严肃。正好自己比较喜欢简书IOS版以及新浪微博的下拉动画,给人的感觉是简练,并且提示清晰。所以这次自己也采用了这种动画。现在的效果如下:

这里的改动并没有什么难度,主要的思路仍然是根据滑动的距离让ViewGroup进入不同的状态(当然自己这次优化了onTouchEvent中根据滑动举例切换视图状态的实现细节),而后根据不同的状态来显示不同的提示信息。而对于旋转的提示箭头,只需要一张向下的箭头素材 + 属性动画就可以搞定了。


二、onMeasure和onLayout的思路优化

在自己最初的实现里,onMeasure和onLayout这里就一直是自己不太满意的。我最初的思路是,既然是一个可上拉、下拉的ViewGroup,所以考虑选择将ViewGroup里的child view按照定义的先后顺序由上至下进行排列。导致后来只要稍微一回想,就会觉得这种方式实在是非常的想当然和糟糕。

这种方式直接导致我对于ViewGroup中的child view的measure工作以及后续的一系列滑动逻辑变得非常的十分缺乏逻辑严密性和合理性。举例来说,我不得不在进行滑动冲突的处理的时候选择:如果是处理下拉的滑动冲突的时候,只能通过getChildAt(firstChildIndex)这样的方式来判断位于最上方的子View是否需要处理滑动冲突;同理,在处理上拉时,则需要判断getChildAt(lastChildIndex)是否存在滑动冲突。

这样的处理方式时候让我越看代码,越有一种要犯尴尬癌的冲动。于是,这也成了自己着重想要进行优化和改变的地方。为此,自己抽空去阅读了SwipeRefreshLayout的源码。而最终也是选择借鉴了SwipeRefreshLayout的实现方式。从而不得不感叹:佩服!至于为什么这么说,我们先来看这样的一个布局文件。并且,思考一下其最终呈现出来的效果应该是怎么样的?

<android.support.v4.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:orientation="vertical">

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="button" />

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@mipmap/ic_launcher" />
    </LinearLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="1200dp"
        android:background="#000000"/>
</android.support.v4.widget.SwipeRefreshLayout>

在这个布局文件的最终效果呈现到屏幕上之前,不知道你对它最终效果的猜想是如何的呢?如果你和我之前一样无法确定,那这里可以一起来看一下:

没错最终的效果就是这样,可以发现:

  • 虽然我们在布局文件中将SwipeRefreshLayout内的LinearLayout的高度设置为300dp,但其实最终的效果看上去是match_parent。
  • 虽然我们已经明确将这之后的一个View的高度设置为了1200dp,但其实然并卵,这个View最终是无法显示到屏幕上的。

形成这种效果的原因,显然我们应该到SwipeRefreshLayout的onMeasure和onLayout中去寻找答案。首先,我们截取部分onMeasure的代码:

public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (mTarget == null) {
            ensureTarget();
        }
        if (mTarget == null) {
            return;
        }
        mTarget.measure(MeasureSpec.makeMeasureSpec(
                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
        // 省略若干...
        }

可以看到这里首先会进行一个mTarget是否为空的判断。如果为空,则会调用叫做ensureTarget()的方法。顾名思义,该方法就是用来确认mTarget的。

    private void ensureTarget() {
        // Don't bother getting the parent height if the parent hasn't been laid
        // out yet.
        if (mTarget == null) {
            for (int i = 0; i < getChildCount(); i++) {
                View child = getChildAt(i);
                if (!child.equals(mCircleView)) {
                    mTarget = child;
                    break;
                }
            }
        }
    }

其实这个方法的实现,非常简单,就是遍历SwipeRefreshLayout中的child view,遍历到的第一个不为mCircleView的child就将作为mTarget。
那么,mCircleView这个东西究竟是什么鬼呢?其实就是SwipeRefreshLayout在进行下拉的时候的那个圈圈了,这是SwipeRefreshLayout默认添加的。
然后,让我们回到onMeasure方法当中,就会看到对mTarget进行测量的代码。从源码中可以看到,其实无论我们对mTarget的宽高进行如何的设置,其实其最后的宽高都是EXACTLY模式的SwipeRefreshLayout的宽高减去内边距。这就解释了为什么对LinearLayout设置300dp的高度最终却占满了窗口。

最后,我们再看一看SwipeRefreshLayout的源码中onLayout方法的实现:

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        final int width = getMeasuredWidth();
        final int height = getMeasuredHeight();
        if (getChildCount() == 0) {
            return;
        }
        if (mTarget == null) {
            ensureTarget();
        }
        if (mTarget == null) {
            return;
        }
        final View child = mTarget;
        final int childLeft = getPaddingLeft();
        final int childTop = getPaddingTop();
        final int childWidth = width - getPaddingLeft() - getPaddingRight();
        final int childHeight = height - getPaddingTop() - getPaddingBottom();
        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
        int circleWidth = mCircleView.getMeasuredWidth();
        int circleHeight = mCircleView.getMeasuredHeight();
        mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop,
                (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight);
    }

由此,我们可以发现对于SwipeRefreshLayout来说:其实不论是measure和layout工作,都只会针对于mTarget,即第一个child进行的。那么就可以解释为什么之前我们放置在LinearLayout之后的高度设置为1200dp的View根本就不会显示的原因了。

现在,了解了其中秘密。我们来分析一下为什么说这种方式更为的优秀呢?很简单,因为我们根本的需求是定义一个支持下拉(上拉)的ViewGroup。
那么注意了!我们要明确在这里:支持下拉、上拉才是重点。所以说,这时我们其实根本不用去过多的考虑child view的measure和layout工作。

对于onMeasure来说,对于mTarget的宽、高的测量,其实就是需要与我们自定义的ViewGroup保持一致才对(当然需要计算内边距)。因为试想一下:

  • 假设SwipeRefreshLayout的宽度是200,里面的child内容却是50,那么看上去不是很奇怪吗?
  • 同理,假设SwipeRefreshLayout的高度是500,而其中的child高度却是1000,那不是又很尴尬了吗?

而对于child的置位工作来说,Android本身就已经就为我们提供了足够的ViewGroup(Layout)类型,如果有复杂的置位需求,使用这些ViewGroup不就行了吗?而抛开了这些后顾之忧,有一个最大的好处就是:我们之后对于滑动冲突等事物的处理将变得明确,只需要针对于mTarget就可以了。

所以,最终我也选择借鉴SwipeRefreshLayout的方式。之前的文字描述可能表达并不清晰,为了更加直观看到我想描述的效果,看这样一个布局文件:

<me.hwang.widgets.SmartPullableLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:library="http://schemas.android.com/apk/res-auto"
    android:id="@+id/layout_pullable"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    library:smart_ui_enable_pull_up="false">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="20dp">

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="按钮一"/>

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="按钮二"/>

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="按钮三"/>

        <ImageView
            android:id="@+id/iv_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@mipmap/ic_launcher"/>
    </LinearLayout>
</me.hwang.widgets.SmartPullableLayout>

其最终的运行效果如下:

也就说,这里我的布局里其实并不止一个单独的View,其包括3个Button以及1个ImageView。但是,对于它们的测量与置位工作,显然不应该是我们这里定义的ViewGroup应该关心的。对于此类逻辑,让它交给LinearLayout,RelativeLayout这类ViewGroup不就OK了吗?当然了,是不是说这种方式就能做到万无一失了呢?显然不是,以SwipeRefreshLayout为例。假设写出了类似下面这样的布局的话,它也是会很纠结的:

<android.support.v4.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
  
        <ListView
            android:id="@+id/lv_content"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"/>

        <Button
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />
    </LinearLayout>
</android.support.v4.widget.SwipeRefreshLayout>

这种写法显然就会造成需求不明的情况,因为控件的作者在定义ViewGroup的时候肯定猜不到你的目的到底是针对于整个LinearLayout进行下拉刷新,还是针对于里面局部的ListView进行刷新。那么,显然这时就无法避免的会出现滑动bug。所以如果你的需求是后者,那显然应该使用如下的方式才对:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
  
    <android.support.v4.widget.SwipeRefreshLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">
        
        <ListView
            android:id="@+id/lv_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </android.support.v4.widget.SwipeRefreshLayout>

    <Button
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
</LinearLayout>

三、添加滑动阻力

这一点是写了之前一篇博客之后,有一个朋友提出来的,我自己也觉得非常需要。同样为了有一个比较直观的印象,来看一看是否添加阻力的效果对比:

这里可以看到,最初没有添加滑动阻力的时候,当我们快速的向下滑动屏幕,会发现整个视图飞快的下滑。这种体验显然不是很好。并且,在这里我是做了判断,当滑动的距离达到一定数值,就不再允许滑动。否则,当我们快速的滑动一段举例,可能会出现视图滑动了十万八千里的情况。与之对比:

可以看到加入了滑动阻力过后,整个滑动给人的体验确实要舒服不少。并且,这时我们也不再需要加入滑动达到一定距离后,便不再允许滑动的判断了。因为加入了滑动阻力之后,当滑动达到一定距离之后,就很难再让view继续产生滑动了。

要为滑动添加上这种所谓的阻力其实也非常简单,我们可以设置一个常量作为阻力因子,比如说0.5。那么,我们每次让滑动的实际距离乘以阻力因子,不就是所谓的阻力了吗?进一步来说,我们还可以在滑动距离达到可以进行释放刷新的距离之后,再乘以一次阻力因子,这样阻力就进一步的加大了。由此就会给人一种越往下越拉不动的感觉。


四、滑动冲突与事件拦截处理

在之前的实现当中,针对于滑动冲突。比如说与ListView配合使用,我的思路是覆写onInterceptTouchEvent,在这里进行逻辑处理。比如如果是下拉的操作,则首先判断ListView是否已经滑动到顶部。如果是,则将拦截事件滑动事件,自己处理滑动。否则则不拦截,让ListView自身进行滑动。这里的逻辑其实是没有问题的。但是,之前因为对ListView,具体来说应该是AbsListView了解不够深入。最后发现会有很让人不爽的一点:

我在上述截图中的操作是先让ListView向下滑动一点距离,然后又接着向上滑动。这时可以看到,虽然当我已经把ListView滑动到最顶端,然后再继续下拉的时候,实际是不起作用的。这是为什么呢?原因在于AbsListView内部在处理触摸事件的时候,会有类似如下的代码处理:

            final ViewParent parent = getParent();
            if (parent != null) {
                parent.requestDisallowInterceptTouchEvent(true);
            }

也就是说,当AbsListView决定自己开始处理滑动的时候,则会通过调用父视图的requestDisallowInterceptTouchEvent禁止父视图拦截事件。简单来说,就是这时候AbsListView已经开始耍流氓了,导致后续的触摸事件根本就不会再经过我们自定义的ViewGroup内的onInterceptTouchEvent。这就意味着,这个时候我们对于滑动冲突的逻辑判断根本就不会执行,最终自然也就无法处理触摸事件了。这个问题的解决方法其实说难也不难,灵感同样来自于SwipeRefreshLayout。查看SwipeRefreshLayout的源码,可以看到如下代码:

    public void requestDisallowInterceptTouchEvent(boolean b) {
        // if this is a List < L or another view that doesn't support nested
        // scrolling, ignore this request so that the vertical scroll event
        // isn't stolen
        if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView)
                || (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) {
            // Nope.
        } else {
            super.requestDisallowInterceptTouchEvent(b);
        }
    }

这段代码的作用就和注释中描述的一样,防止在child view同样具有垂直滑动能力的时候“偷”走滑动事件。在这之后,就可以解决上面谈到的问题了:


五、NestedScroll 加入嵌套滑动机制

当完成了之前谈到的一点的改造之后,是否还有继续改进的余地呢?显然时候有的。因为以之前谈到的ListView的改进来说:虽然相对最初的效果体验好了不少。但是,归根结底,仍然没有脱离通过事件拦截机制来处理滑动冲突的原理。这样有一个非常显著的问题就是,只要当我们自定义的作为Parent View的ViewGroup决定拦截事件过后:那么,很遗憾的,本次的一系列TouchEvent就不会再有机会传递给下面的childView(比如说ListView)了。

但对于RecyclerView这类更为“年轻”的控件来说,这个问题就不再是无解的了。因为Android在Lollipop版本之后,加入了一个牛逼的机制叫做Nested Scroll,即嵌套滑动。而RecyclerView自身就是支持这种机制的。于是,这次我也决定为自定义的ViewGroup加上这种玩意儿。

可以看到在加入嵌套滑动的处理后,这种体验效果显然是最好的。因为我们自定义的ViewGroup与作为child view的RecyclerView之间完成了非常默契的滑动配合。这里不会对NestedScroll做详细的介绍,因为能力和篇幅都有限,有兴趣的朋友可以自己查阅相关资料。

简单的介绍一下核心的实现思路,总的来说,我们只需要知道:

  • 首先,RecyclerView作为NestedScrollingChild,其在每次处理滑动之前会先通知NestedScrollingParent是否需要进行嵌套滑动。
  • 这时,如果Parent决定进行嵌套滑动。那么,在Child处理滑动之前,Parent可以首先在onNestedPreScroll预先进行滑动。
  • 最后,当Child在处理过滑动之后,还会通知parent执行onNestedScroll。在这里,Child没有消耗的y轴滑动举例将作为参数dyUnconsumed传入。

有了这些基础,我们可以做的工作是:

  • 在onNestedPreScroll中,如果我们自定义的ViewGroup已经发生过滑动,那么我们需要先进行滑动,直到ViewGroup恢复到初始的位置。
  • 反之,如果在onNestedPreScroll时,ViewGroup没有发生过滑动,那么就没有必要进行预消耗。直接让NestedScrollingChild处理滑动就行了。
  • 最后,我们说到在onNestedScroll中,NestedScrollingChild没有消耗掉的滑动距离将会通过参数dyUnconsumed传入。那么,我们要做的就是在这里把这些NestedScrollingChild没有消耗完的距离给消耗掉。

总结

好了,以上差不多就是这次对于自定义上拉加载、下拉刷新控件的优化工作的思路总结以及收获。再次感叹,阅读源码可能真的是一件有些蓝瘦但却又最能带来收获的事情了,真是叫做有苦有甜。像以上谈到的种种改动,很多的启发都来自于SwipeRefreshLayout当中。除此之外,像当时在阅读当中onMeasure的实现方式时,因为一些细节上的困惑,同时又逼迫自己重新走了一遍Android中View的绘制流程,从而又有不少以前自己忽略掉的收获。至于本文中项目的源码已经上传到github,如果感兴趣的朋友,具体的实现细节都可以参照源码,多多指教。

https://github.com/RawnHwang/SmartAndroidWidgets

推荐阅读更多精彩内容

  • 前段时间自己写了一个能够“通用”的,支持下拉刷新和上拉加载的自定义控件。可能现如今这已经不新鲜了,但有兴趣的朋友还...
    Machivellia阅读 6,499评论 19 114
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 158,523评论 24 688
  • 前两天,简书上一个很尊敬的姐姐看了我的文章,称赞我的育儿文章里有智慧,真是受宠若惊!其实,我真的不是一个有智慧的人...
    心向暖阅读 303评论 8 13
  • 是的 夏天要来了 风都变的温柔了 不像之前那么冰冷无情 树都绿了 是那种很舒服的绿 让我很放松的绿色 每次心情不好...
    梅子酒_阅读 48评论 0 0
  • 管理者的成本意识是公司生存的基础,管理者的利润意识是公司发展的需要。 不同的部门有不同的贡献方式,比如各个店面,高...
    曲同宁阅读 58评论 0 1