android: 用NestedScrollingParent2 打造一款流畅的下拉刷新上拉加载控件

3,157 阅读13分钟

NestedScrollingParent2打造一款流畅的下拉刷新上拉加载控件

前言:

NestedScrollingParent2 是谷歌推出的嵌套滑动接口,与其配套的是NestedScrollingChild2

使用传统的方式去实现一个下拉刷新控件,一定会涉及到事件分发的处理。要去判断当前应该让触摸事件交给父控件去处理还是子控件。然后是要去记录手指移动的距离进行相应的滑动;以及如果是惯性滑动的话,还得处理对应的fling, 这又得去获取速度等参数。总之,就是比较麻烦。

NestedScrollingParent2 的出现解决了上述的麻烦。

为什么说是使用 NestedScrollingParent2 去打造该控件,而不是 + NestedScrollingChild2一起呢?

因为系统控件RecyclerView 已经实现了 NestedScrollingChild2 的接口。所以,我们只用处理 NestedScrollingParent2 即可。

  • 当然,这也表示,目前该控件只支持所有实现 NestedScrollingChild2的控件,进行下拉刷新,以及上拉加载。
  • 不过,幸运的是,目前RecyclerView的使用场景远远大于ListView.

如果一定要去使用 ListView/ScrollView作为子控件。就... 可以根据该控件进行适当的修改,然后去使用。

实现攻略

插个队,先看个效果吧

下拉刷新的效果

下拉刷新的效果

上拉加载更多的效果

加载更多的效果

  1. 明明已经开了‘显示点按操作反馈’了,但是依然看不到手指的痕迹~🤣
  2. 由于录制时间过长,导致转成 gif之后显示的不太友好。凑合看吧~😹
  3. 只是展示了控件的效果,并没有真的去更新数据~😹

思路

  • 先预期一下控件的布局。最上面是一个刷新头布局,默认是隐藏的;然后中间显示的是一个RecyclerView,默认是显示的;底部有一个加载更多的尾布局,默认也是看不见的。

为了方便表述,后续 RecyclerView 直接简写成 rvRV;

  • 对于上述的布局,我们可以直接继承线性布局,然后做少了的修改即可完成。
  • 头布局的处理:
    • 头布局要默认隐藏, 可以设置高度为0;可以设置 marginTop 和自己的高度相同;
    • 我这里的实现是采用方式二,将 marginTop 和自己的高度相同
    • 为什么不使用方式一?因为这样会在下拉的过程中动态改变高度,导致不停调用其measure 以及layout, 对性能不友好。
  • RecyclerView及尾部局的处理:
    • 尾部局也是默认隐藏的。不过我们得测量到其高度,所以,我们不能直接把RecyclerView高度设置为match_parent, 这会导致尾巴不能被正确测量到。
    • 我们可以给rv设置一个固定高度先,比如10dp,或者 layout_height=0dp; layout_weigit=1; 这样尾部局就可以正常被测量到了。
  • 以上的操作并没有完成全部的布局。因为这会导致rv高度是一个固定值或者是自己的高度-尾巴的高度。
  • 解决:重写onMeasure , 让rv高度与自己默认高度相同;而自己的高度成为rv.height + footer.height即可。
  • 静态的布局完成了,后续我们要预期一下滑动的处理。如果当前是滑动是应该交给rv的,我们不处理,让rv自己去滑动就可以了;如果是要显示头布局,隐藏头布局;显示尾巴,隐藏尾巴;我们就自己处理。

这里不得不了解一下 NestedScrollingParent2 提供的方法了。

onStartNestedScroll : 对应 startNestedScroll, 内控件通过调用外控件的这个方法来确定外控件是否接收滑动信息.

onNestedScrollAccepted : 当外控件确定接收滑动信息后该方法被回调, 可以让外控件针对嵌套滑动做一些前期工作.

onNestedPreScroll : 关键方法, 接收内控件处理滑动前的滑动距离信息, 在这里外控件可以优先响应滑动操作, 消耗部分或者全部滑动距离.

onNestedScroll : 关键方法, 接收内控件处理完滑动后的滑动距离信息, 在这里外控件可以选择是否处理剩余的滑动距离. onStopNestedScroll : 对应stopNestedScroll, 用来做一些收尾工作.

quote: 利用Android嵌套滑动机制轻松实现顶部布局置顶

以上的介绍来自网上博客,介绍的比较清楚了。

虽然其是对NestedScrollingParent 进行介绍的,但是 NestedScrollingParent2 对于 NestedScrollingParent 的变化并不大,注意是在每个方法里面多加了一个参数int type,用来指明当前的触发是来自触摸滑动,还是惯性滑动的。然后去掉了NestedScrollingParent#onNestedFling 相关的,因为这些fling 其实就是惯性滑动了,直接在onNestedScroll里面处理即可。

  1. 那么,我们肯定要去处理 onStartNestedScroll,因为我们是要处理滑动的。并且,我们要去限定,只处理纵向的滑动;
  2. 我们也要处理 onStopNestedScroll,在下拉显示头,或者上拉显示尾巴之后放手,我们要让其隐藏对应的头尾布局。
  3. 我们还要处理 onNestedPreScroll,在rv滑动到顶部的时候,如果手指继续下拉,我们要让自己滑动,好显示刷新头;同理,在rv滑动到尾部的时候,如果手指继续上拉,我们要让自己滑动,好显示尾巴(尾巴就是加载更多的布局)。
  4. 我们依然要处理 onNestedPreScroll , 因为如果头尾目前在显示中,而且没有松开手指,并且手指在像反方向去滑动的时候,用户的预期是主动隐藏头或者尾巴。那么我们的行为要符合预期,也就是,滑动自己隐藏头尾;然后再让rv继续滑动。

好了,以上就是我们的整体实现思路

细节分析

  • 在惯性滑动rv的时候,能不能让头部或者尾巴被带出来显示?
  • 不能,因为这不太符合用户预期。要让用户主动用手指去滑,才显示出头尾布局。
  • 在头尾显示中,用户放手,怎么让其平滑恢复原样?
  • 利用 Scroller 去完成该行为。
  • 下拉或者上拉到什么程度才去触发刷新。
  • 下拉或者上拉到完全显示头或者尾,就去触发刷新。(这个策略可以修改)
  • 在刷新过程中,让不让用户继续滑动?
  • 此时不让用户滑动,等恢复原样了,才能继续滑动。(这个策略也可以修改,不过实现会麻烦一点,或者很麻烦)
  • 如何通知调用者,已经触发了下拉刷新或者上拉加载?
  • 暴露接口让调用者去实现。类似 SwipeRefreshLayout#setOnRefreshListener这种。
  • 调用者这边数据刷新完成了,怎么通知我加载完成了,应该让刷新头隐藏掉了?
  • 暴露公共方法,让调用者主动通知我。类似SwipeRefreshLayout#setRefreshing(boolean)
  • 下拉刷新中其实有很多状态,比如:下拉状态,刷新中状态,刷新完成状态;这些状态往往要在刷新头中去切换,如何去实现?让调用者根据不同状态去做不同显示;还是?(加载更多同理。)
  • 如果让调用者根据不同状态去显示,意味着得暴露更多接口让调用者去实现,非常的麻烦;但是如果写死每种状态的显示,如果不同的页面要的状态显示文案不同,又得多次修改。真是一个两难的问题。这时候可以采取自定义属性去操作;让调用者在自定义属性中分别配置好不同的文案,然后自己去根据不同状态显示对于的属性中获取的状态显示文案即可。

以上是目前想到的一些需要处理的细节,以及对应的解决方案。

代码实践

在完成大致的思路以及细节分析之后,我们就来进行真正的写代码了。

按照之前的分析,xml 里面的控件需要3个,不能多,也不能少。多了或者少了,对应的onMeasure需要修改,对应的nestedScroll相关的处理也要修改。

  • 那么,我们先写布局。

<com.python.cat.mvvm.widgets.NestedRefreshLayout
    android:id="@+id/nested_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/light_gray3"
    android:orientation="vertical"
    app:loadDoneText="@string/do_load_done"
    app:loadInitText="@string/refresh_footer"
    app:loadStartText="@string/drop_to_load"
    app:loadingText="@string/do_load_now"
    app:refreshDoneText="@string/do_refresh_done"
    app:refreshDrawable="@drawable/ic_replay_black_24dp"
    app:refreshInitText="@string/refresh_header"
    app:refreshStartText="@string/drop_to_refresh"
    app:refreshingText="@string/do_refresh_now">

    <LinearLayout
        android:id="@+id/refresh_header"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_marginTop="-100dp"
        android:contentDescription="@string/refresh_header"
        android:orientation="vertical">

        <ImageView
            android:id="@+id/header_refresh_img"
            android:layout_width="45dp"
            android:layout_height="45dp"
            android:layout_gravity="center_horizontal"
            android:contentDescription="@string/refresh_header"
            android:gravity="center"
            android:src="@drawable/ic_replay_black_24dp"
            android:textColor="@color/white" />

        <TextView
            android:id="@+id/header_refresh_tv"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:gravity="center"
            android:text="@string/refresh_header"
            android:textColor="@color/normal_color" />
    </LinearLayout>
    <!--
    这里不能将其设置成 match_parent , 导致 footer 不能被测量到
    可以直接写成 0dp, 反正真正的测量是在 parent#onMeasure 完成的
    -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/articles_list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

    <LinearLayout
        android:id="@+id/refresh_footer"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:contentDescription="@string/refresh_header"
        android:orientation="vertical">

        <TextView
            android:id="@+id/footer_refresh_tv"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="5dp"
            android:gravity="center"
            android:text="@string/refresh_footer"
            android:textColor="@color/normal_color" />

        <ImageView
            android:id="@+id/footer_refresh_img"
            android:layout_width="60dp"
            android:layout_height="60dp"
            android:layout_gravity="center_horizontal"
            android:layout_marginTop="10dp"
            android:contentDescription="@string/refresh_header"
            android:gravity="center"
            android:src="@drawable/refresh_progress" />

    </LinearLayout>

</com.python.cat.mvvm.widgets.NestedRefreshLayout>

看我们的布局文件,有点长。这里的头尾布局完全可以通过<include/> 标签去添加,这样可以让其短一点。然后里面又写了很多的自定义属性,导致其变的有点长了。

其中有一个属性是android:orientation="vertical",这个属性很重要,因为我们是继承自线性布局的,就直接让其竖直排列。

然后要注意,我在 header里面写了一个属性是android:layout_marginTop="-100dp",因为我们定义了其高度是android:layout_height="100dp"。(这样表示该控件对应头部必须是固定值高度,如果你设置为wrap_content,那么你的marginTop,你就不知道怎么写了。这个可以通过其他方式去处理,不在这里展开。)

  • 然后是控件。

这里不写自定义属性处理相关的了,因为与主题无关。

首先处理onMeasure, 测量处理好了,布局就能正常显示了。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int hs = MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY);
        int ws = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY);
        
        View recyclerV = getChildAt(1);
        View footer = getChildAt(2);
        int selfHeight = getMeasuredHeight() + footer.getMeasuredHeight();
        setMeasuredDimension(getMeasuredWidth(),
                selfHeight);
        
        ViewGroup.LayoutParams lp2 = recyclerV.getLayoutParams();
        
        lp2.height = MeasureSpec.getSize(hs);
        measureChildWithMargins(recyclerV, ws, 0, MeasureSpec.makeMeasureSpec(selfHeight, MeasureSpec.EXACTLY), 0);
    }

代码不多,不过这就实现了我们要的效果。我们分析一下,首先因为布局里面给NestedRefreshLayout设置的高度是match_parent的,也就是自己的预期高度是与自己的parent高度相同的。那么,我们可以根据这个预期去设置rv的高度,让rv变成自己的预期高度。同时,我们要让自己变成自己预期的高度加上footer的高度。

也就是说,经过测量之后,自己的高度实际上比自己的parent的高度要高出一个footer.height的高度的。

测量之后,布局这块就完成了。因为是继承的线性布局,所以不必要重写onLayout了。

下面就是除了嵌套滑动相关的,如显示刷新头,加载尾,以及隐藏头尾;还有,加载中,刷新中的动画这些。

  • 说明一下:

相对于 NestedScrollingParent 而言,要注意是每个NestedScrollingParent2 里面的每个方法回调次数基本是同等操作下 NestedScrollingParent 同名方法被回调次数的两倍。

比如:onStartNestedScroll 默认每次滑动之后调用一次。但是,你的手指滑动往往在松手之前会有一个fling操作,也就导致,onStartNestedScroll会再次回调。第一次,回调方法里面的type == ViewCompat.TYPE_TOUCH; 第二次 type == ViewCompat#TYPE_NON_TOUCH

其他方法同理。

@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target,
                                   int axes, int type) {
    // 表明只处理纵向滑动,不处理横向滑动,这也是首先被回调到的方法
    // 一次滑动过程中,只会被回调一次或者两次。
    // 一次是 type=touch , 另一次 是type= not_touch
    return (axes == ViewCompat.SCROLL_AXIS_VERTICAL);
}
@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target,
                                   int axes, int type) {
    // 这个方法会紧接着 onStartNestedScroll 之后被回调
    // 一次滑动过程中,也是只会被回调两次;【一次 touch ,一次 not-touch】
    
    // 我在这里做了刷新头,加载尾的重置工作。
    // 比如上次刷新之后,刷新头的文案变成了 “刷新完成” ,在这里改成 “下拉刷新”
    resetHeaderState();
    resetFooterState();
    clearAllAnimator();
}
// 这个方法会在 onNestedScrollAccepted 被调用之后被回调
// 并且会多次调用,直到松手

// 这时候可以用来处理隐藏头尾的操作,因为这个方法是优先于 child 的滑动进行调用的。
// 也就是说如果直接在这里完全接收 dy,可以让 child 永远滑动不了
    
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy,
                              @NonNull int[] consumed, int type) {

    if (1 == 2) {
        consumed[1] = dy; // 这样会导致界面整个不能动了!~ bingo!
        // 因为你主动消费了全部滑动距离,但实际上没有做任何的滑动逻辑。
        return;
    }
    // dy < 0 ,表示手指下滑;>0 上滑
    boolean fetchTargetTop = !target.canScrollVertically(-1); 
    boolean fetchTargetBottom = !target.canScrollVertically(1);

    int firstHeight = getChildAt(0).getHeight();
    int lastH = getChildAt(2).getHeight(); // footer height

    boolean hideTop = dy > 0 && getScrollY() < 0;
    boolean hideBottom = dy < 0 && getScrollY() > 0;
    TextView tvRefresh = getChildAt(0).findViewById(R.id.header_refresh_tv);
    if (getScrollY() <= -firstHeight) {
        // 头完全显示了
        tvRefresh.setText(mRefreshStartText);
    } else {
        // 头不完全显示,或者直接看不见
        tvRefresh.setText(mRefreshInitText);
    }

    View lastV = getChildAt(getChildCount() - 1);
    TextView loadMreTv = lastV.findViewById(R.id.footer_refresh_tv);
    if (getScrollY() >= lastH) {
        loadMreTv.setText(mLoadStartText);
    } else {
        loadMreTv.setText(mLoadInitText);
    }

    if (hideTop) {
        // 要调整一下
        // dy < 0 ,表示手指下滑;>0 上滑
        if (getScrollY() + dy > 0) {
            dy = 0 - getScrollY();
        }
        LogUtils.e("scrollBy zz : %s", dy);
        scrollBy(0, dy);
        consumed[1] = dy;
    } else if (hideBottom) {
        LogUtils.w("scrollBy zz : %s", dy);
        // 要调整一下
        // dy < 0 ,表示手指下滑;>0 上滑
        if (getScrollY() + dy < 0) {
            dy = 0 - getScrollY(); // 不让其多滑动
        }
        LogUtils.e("scrollBy zz : %s", dy);
        scrollBy(0, dy);
        consumed[1] = dy;
    }
}

@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
                           int dxUnconsumed, int dyUnconsumed, int type) {
    // dy < 0 ,表示手指下滑;>0 上滑
    boolean fetchTargetTop = !target.canScrollVertically(-1); //
    boolean fetchTargetBottom = !target.canScrollVertically(1);

    int firstHeight = getChildAt(0).getHeight();
    int lastH = getChildAt(2).getHeight(); // footer height

    boolean showTop = fetchTargetTop && dyUnconsumed < 0 && getScrollY() <= 0
            && type == ViewCompat.TYPE_TOUCH;
    boolean showBottom = fetchTargetBottom && dyUnconsumed > 0 && getScrollY() >= 0
            && type == ViewCompat.TYPE_TOUCH;

    // 加上一个 =0,包含边界情况
    boolean moreHead = showTop && getScrollY() <= -firstHeight;
    boolean moreBottom = showBottom && getScrollY() >= lastH;

    TextView tvRefresh = getChildAt(0).findViewById(R.id.header_refresh_tv);
    if (getScrollY() <= -firstHeight) {
        // 头完全显示了
        tvRefresh.setText(mRefreshStartText);
    } else {
        // 头不完全显示,或者直接看不见
        tvRefresh.setText(mRefreshInitText);
    }

    View lastV = getChildAt(getChildCount() - 1);
    TextView loadMreTv = lastV.findViewById(R.id.footer_refresh_tv);
    if (getScrollY() >= lastH) {
        loadMreTv.setText(mLoadStartText);
    } else {
        loadMreTv.setText(mLoadInitText);
    }

    if (moreHead) {
        if (!hasRefreshFeedback) {
            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS,
                    HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING);
            hasRefreshFeedback = true;
        }

        scrollBy(0, dyUnconsumed);
    } else if (showTop) {
        // dy < 0 ,表示手指下滑;>0 上滑
        LogUtils.e("scrollBy zz : %s", dyUnconsumed);
        scrollBy(0, dyUnconsumed);
    } else if (moreBottom) {
        if (!hasLoadMoreFeedback) {
            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS,
                    HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING);
            hasLoadMoreFeedback = true;
        }
        scrollBy(0, dyUnconsumed);
    } else if (showBottom) {
        LogUtils.e("scrollBy zz : %s", dyUnconsumed);
        // dy < 0 ,表示手指下滑;>0 上滑
        LogUtils.e("scrollBy zz : %s", dyUnconsumed);
        scrollBy(0, dyUnconsumed);
    }
}

@Override
public void onStopNestedScroll(@NonNull View target, int type) {
    LogUtils.i("onStopNestedScroll: %s,%s, sy=%s", target, type, getScrollY());

    if (type != ViewCompat.TYPE_TOUCH) {
        // 每次 fling 操作会走这里
        return;
    }
    View head = getChildAt(0);
    View foot = getChildAt(getChildCount() - 1);
    if (getScrollY() <= -head.getHeight()) { // 头布局完全显示才去刷新
        autoScroll = true;
        hasRefreshFeedback = false;
        mRefreshDone = false;
        showRefreshAnimator();
    } else if (getScrollY() > foot.getHeight()) {
        // load more 相关
        autoScroll = true;
        hasLoadMoreFeedback = false;
        mLoadMoreDone = false;
        showLoadMoreAnimator();
        // some codes ...
    } else if (getScrollY() != 0) {
        autoScroll = true;
        smooth2Normal();
    }
}

代码基本完成了。不过,还要提供回调让调用者可以调用对应的刷新,加载更多。好去处理真正的加载,刷新逻辑。

public interface OnRefreshListener {
    /**
     * Called when a swipe gesture triggers a refresh.
     */
    void onRefresh();
}

public interface OnLoadMoreListener {
    /**
     * Called when a swipe gesture triggers a load-more.
     */
    void onLoadMore();
}

然后提供一下对应的 set方法即可。

这部分代码跟嵌套滑动本身无关,也不加这里了。

以上,就打造完成了一款流畅的下拉刷新上拉加载的控件了。当然,目前只针对rv实现。

最后,特别鸣谢