Android中NestedScrollview的使用

作者: wxyass 分类: Android 发布时间: 2017-03-19 17:44

转载:
Android中NestedScrollview的使用

之前我们对RecyclerView进行了了解,和RecyclerView绝配的就是NestedScrollView,这两者的结合体现了新一代滑动机制的诞生。在这之前我们的ListView和ScrollView的嵌套滑动一直让人很困惑,如今滑动的问题终于解决了。所以这篇我们就来对NestedScrollView进行一下了解。

如果对NestedScrollView单独使用的话,和ScrollView没有区别,效果还是这样:

如果我们把RecyclerView和NestedScrollView混合使用就是发生奇妙的事情了。有两种情况,一种是我设计的页面就是嵌套滑动,里面能滚动,外面也能滚动,效果就是:

这时候我们只需要在NestedScrollView里直接添加RecyclerView即可,即把RecyclerView当作一个普通的控件放进去它就可以滑动,外面的NestedScrollView也可以滑动,当然,这个效果的前提是RecyclerView的高度是一个固定值,比如400dp。当然,这种嵌套滑动虽然没有问题了,但是设计师通常也不会设计这么不友好的界面。所以我们有了第二种情况,即我们之前说的,为了让ListView上下能加一些东西从而使整个页面能够上下滑动,即

在之前使用ListView时,碰到这样的情况,我们分析了如果把ListView和ScrollView嵌套使用,那么会带来滑动无效,即使滑动有效了也会使控件复用失效的问题,所以当时我们得出结论,不应该使用ListView和ScrollView嵌套的方式实现,而是应该使用ListView加HeaderView和FooterView的方式来使整个滑动。那么如今有了RecyclerView和NestedScrollView会解决这个问题吗?很遗憾的说,这两个控件单纯的直接嵌套,只解决了一个问题,那么就是嵌套滑动的交互没有问题了,但是控件复用还是有问题。即,上面我们说了,如果内外均可滑动,需要把RecyclerView的高度定死,如果高度是wrap_content或者match_parent呢?答案就是RecyclerView会被完全展开,有多少条数据就会创建多少个item,所以控件复用也就失效了。所以说如果想要制作一个完美的嵌套滑动的RecyclerView和NestedScrollView通用代码还是比较费事的,这个我们在之后再做说明。我们现在来想一个关键的问题。为什么之前的控件嵌套起来就会有很多问题,但是RecyclerView和NestedScrollView就可以随便嵌套呢?答案就是今天要说的NestedScroll-嵌套滚动机制。为什么要说NestedScroll机制,因为在这之前,Android的事件分发与处理都是控件单方面处理的,即要不就是子View处理,要不就是父View处理。而有了NestedScroll机制,父View和子View就可以同时处理一个事件了(对于之前的事件分发处理机制会在其他文章讲解,这个是重点内容)。我们先忽略RecyclerView和NestedScrollView这俩控件,从整体考虑,如果要实现NestedScroll嵌套滑动机制,那么该怎么办,非常关键的几个东西是:

NestedScrollingParent

NestedScrollingChild

NestedScrollingParentHelper

NestedScrollingChildHelper

前两个是接口,后两个是辅助类。为了实现外部的滚动,控件需要实现NestedScrollingParent接口;为了能在内部滚动,控件需要实现NestedScrollingChild接口。例如NestedScrollView实现了NestedScrollingParent接口,所以它可以套别人,例如RecyclerView实现了NestedScrollingChild接口,所以它能被别人套,当然了实际NestedScrollView也实现了NestedScrollingChild接口,即它既可以套别人,也可以被套。我们来看一下这两个接口的抽象方法。

NestedScrollingParent:

public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
public void onStopNestedScroll(View target);
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,int dxUnconsumed, int dyUnconsumed);
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);
public boolean onNestedPreFling(View target, float velocityX, float velocityY);
public int getNestedScrollAxes();

NestedScrollingChild:

public void setNestedScrollingEnabled(boolean enabled);
public boolean isNestedScrollingEnabled();
public boolean startNestedScroll(int axes);
public void stopNestedScroll();
public boolean hasNestedScrollingParent();
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
public boolean dispatchNestedPreFling(float velocityX, float velocityY);

是不是有很多的需要实现的方法,看官方的源码注释也是一堆一堆的。实际上实现嵌套滑动并不是想象中的那么复杂,因为有刚才我们介绍的NestedScrollingParentHelper和NestedScrollingChildHelper两个帮助类帮我们实现大部分的方法,我们只需要关系几个特定的方法就可以了。

为了对这些东西有个了解,我们来实现一个iOS上的吸顶效果:

为了实现这个效果,我们使用嵌套滑动,我们让我们最熟悉的LinearLayout(两个)分别实现NestedScrollingParent和NestedScrollingChild作为父控件滑动和子控件滑动。

public class MyNestedScrollParent extends LinearLayout implements NestedScrollingParent
public class MyNestedScrollChild extends LinearLayout implements NestedScrollingChild

实际上即使不写implements NestedScrollingParent和implements NestedScrollingChild也可以,为什么呢?因为从Android5.0开始View和ViewGroup已经默认实现了这两个接口了,我们只需要复写就可以了。

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


    <com.velsharoon.nestedscrollviewtest.MyNestedScrollParent
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <ImageView
            android:id="@+id/imageview"
            android:layout_width="match_parent"
            android:layout_height="250dp"
            android:scaleType="fitXY"
            android:src="@drawable/header"/>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#654321"
            android:gravity="center_horizontal"
            android:padding="10dp"
            android:text="@string/title"
            android:textColor="#ffffff"/>

        <com.velsharoon.nestedscrollviewtest.MyNestedScrollChild
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/content"
                android:textColor="#123456"
                android:textSize="22sp"/>

            <View
                android:layout_width="match_parent"
                android:layout_height="500dp"/>

        </com.velsharoon.nestedscrollviewtest.MyNestedScrollChild>
    </com.velsharoon.nestedscrollviewtest.MyNestedScrollParent>
</RelativeLayout>

看到以上布局我们可以想象到具体的实现逻辑,那就是:当向上滚动时,上面的图片可见的时候最外面的Parent滚动,如果图片不可见了,那么外面的Parent不要滚动,留下那个title显示,接着里面的Child滚动就可以了;当向下滚动时,Child滚动到最上面的时候让Parent滚动就可以了。为了实现这个逻辑我们就用到了最关键的一个方法,那就是public void onNestedPreScroll(View target, int dx, int dy, int[] consumed)。我们来看一下具体的实现:

@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
    boolean isImageVisiable = (dy > 0 && getScrollY() < mImgHeight) || (dy < 0 && target.getScrollY() <= 0);
    if (isImageVisiable) {
        consumed[1] = dy;
        scrollBy(0, dy);
    }
}

其中target就是内部嵌套的View,这里就是我们的Child,dx是x方向的滑动偏移,dy是y方向的滑动偏移,consumed的含义是消耗,它是一个长度是2的int数组,consumed[0]代表x轴,consumed[1]代表y轴。具体怎么使用呢?和名字一样,就是说子控件滑动时的偏移父控件想要消耗多少。例如我们想要把所有的y轴的滑动事件都消耗掉,那么就让consumed[1] = dy;这样子控件会在父控件的这个方法执行后重新计算还剩余多少可以供自己用,如果父控件的consumed[1] = dy,那么子控件的剩下的就是dy-consumed[1]=0,也就是不进行任何滑动处理了。例如我们可以让consumed[1] = dy/2,这样我们手指滑动时父控件和子控件各滑动一半,这在很多场景下也会使用。在本例中我们使consumed[1] = dy,什么时候用呢?那就是我们刚才说的逻辑:当向上滚动时(dy>0),上面的图片可见的时候(getScrollY() < mImgHeight)最外面的Parent滚动,如果图片不可见了,那么外面的Parent不要滚动,留下那个title显示,接着里面的Child滚动就可以了;当向下滚动时(dy < 0),Child滚动到最上面的时候(target.getScrollY() <= 0)让Parent滚动就可以了。

整个类的代码如下:

import android.content.Context;
import android.support.v4.view.NestedScrollingParent;
import android.support.v4.view.NestedScrollingParentHelper;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import android.widget.LinearLayout;

public class MyNestedScrollParent extends LinearLayout implements NestedScrollingParent {

    private NestedScrollingParentHelper mParentHelper;
    private int mImgHeight;

    public MyNestedScrollParent(Context context, AttributeSet attrs) {
        super(context, attrs);
        mParentHelper = new NestedScrollingParentHelper(this);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        final ImageView imageView = (ImageView) findViewById(R.id.imageview);
        imageView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                if (mImgHeight <= 0) { mImgHeight = imageView.getMeasuredHeight(); } imageView.getViewTreeObserver().removeGlobalOnLayoutListener(this); } }); } @Override public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { return true; } @Override public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) { mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes); } @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { boolean isImageVisiable = (dy > 0 && getScrollY() < mImgHeight) || (dy < 0 && target.getScrollY() <= 0); if (isImageVisiable) { consumed[1] = dy; scrollBy(0, dy); } } @Override public void scrollTo(int x, int y) { if (y > mImgHeight) {
            y = mImgHeight;
        }
        if (y < 0) {
            y = 0;
        }
        super.scrollTo(x, y);
    }

}

可以看到,我们复写的方法没有多少,onFinishInflate后设置Listener获取图片高度.onStartNestedScroll的返回值代表我们的控件是否可以支持父控件的嵌套滑动,这里我们当然要返回true了。既然你写了这个控件那肯定是支持啊,不然还写个蛋蛋。

onNestedScrollAccepted这个方法为了记录滑动的方向,我们直接交给助手来处理就好了,同样,
onStopNestedScroll是滚动结束的回调,直接交给助手处理。

scrollTo的复写就是为了自己滚动的一个校正。

整个Parent实现就是这么简单。接下来看Child的实现。不多说,直接全部代码奉上:

import android.content.Context;
import android.support.v4.view.NestedScrollingChild;
import android.support.v4.view.NestedScrollingChildHelper;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.LinearLayout;

public class MyNestedScrollChild extends LinearLayout implements NestedScrollingChild {

    private NestedScrollingChildHelper mScrollingChildHelper;
    private final int[] mScrollOffset = new int[2];
    private final int[] mScrollConsumed = new int[2];
    private int mLastTouchY;
    private int mVisiableHeight;
    private int mFullHeight;
    private int mCanScrollY;

    public MyNestedScrollChild(Context context, AttributeSet attrs) {
        super(context, attrs);
        mScrollingChildHelper = new NestedScrollingChildHelper(this);
        mScrollingChildHelper.setNestedScrollingEnabled(true);
    }

    @Override
    public boolean startNestedScroll(int axes) {
        return mScrollingChildHelper.startNestedScroll(axes);
    }

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        return mScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (e.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastTouchY = (int) (e.getRawY() + 0.5f);
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
                break;
            case MotionEvent.ACTION_MOVE:
                int y = (int) (e.getRawY() + 0.5f);
                int dy = mLastTouchY - y;
                mLastTouchY = y;
                if (dispatchNestedPreScroll(0, dy, mScrollConsumed, mScrollOffset)) {
                    dy -= mScrollConsumed[1];
                }
                scrollBy(0, dy);
                break;
        }
        return true;
    }

    @Override
    public void scrollTo(int x, int y) {
        if (y > mCanScrollY) {
            y = mCanScrollY;
        }
        if (y < 0) {
            y = 0;
        }
        super.scrollTo(x, y);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mVisiableHeight <= 0) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            mVisiableHeight = getMeasuredHeight();
        } else {
            heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            if (mFullHeight <= 0) {
                mFullHeight = getMeasuredHeight();
                mCanScrollY = mFullHeight - mVisiableHeight;
            }
        }
    }
}

其中mScrollingChildHelper是辅助类,mScrollConsumed是为了给父控件传递来计算消耗值的变量(上面说的int[] consumed),mLastTouchY是为了来计算滑动偏移的,mVisiableHeight、mFullHeight、mCanScrollY三者共同计算出了Child可以滑动的距离。可以看到最重要的只有onMeasure和onTouchEvent,其余的都可以通过助手去解决,对于onMeasure我们在里面计算mCanScrollY-Child可以滑动的距离。为什么会有这个过程?因为既然Child能滑动,那么它看到的大小和它内部的大小就不会一致,两者的差就是可以滑动的距离。第一次执行的时候计算出mVisiableHeight-即我们看到的大小,第二次的时候计算mFullHeight-即里面内容的实际高度,然后两者的插值就是mCanScrollY-可以滑动的距离。
顺便提一句MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);之前在写View的绘制过程时说过,这个就是父控件不去限制子控件的大小,所以子控件会自己决定自己的大小,这样就能量出实际内容的大小了,这个0可以随便传。

对于onTouchEvent最关键的也只有Move时候的:

if (dispatchNestedPreScroll(0, dy, mScrollConsumed, mScrollOffset)) {
    dy -= mScrollConsumed[1];
}
scrollBy(0, dy);

结合之前说的Parent的onNestedPreScroll,我们就能很容易理解Child里的这段代码的含义。dispatchNestedPreScroll(0, dy, mScrollConsumed, mScrollOffset)调用后会如果返回了true,那么证明Parent是要对滑动事件进行消耗的,那么消耗后剩余的就是dy – mScrollConsumed[1];这和我们之前说的Parent的是一致的。这样我们运行后就是我们展示的效果了。有一点缺陷就是手指停止后就不能滚动了,因为还得实现fling效果,所以就不单独加了。除去fling不说,可以看到,实现嵌套滑动,关键的地方就在于事件的处理时机和事件的处理数量两者的判断。

至此效果实现了,回到本文开头,如何实现一个NestedScrollView和RecyclerView嵌套并且能够控件复用的实现呢?有一个想法,那就是让RecyclerView的高度和NestedScrollView的高度一样,具体的场景自行想象一下,在后面提供的demo中我只实现了一点点,其余的有待实现。

至此实现嵌套滑动本身我们已经没问题了,但是我们还没有对嵌套滑动的整个流程进行了解分析。因为Helper做的很好,所以我们不要去关心特别多的实现,所以我们就对NestedScrollView本身做一个简单的分析,因为它既是Parent又是Child。

滑动在onTouchEvent里,经过了三个过程,这三个过程大家应该比较熟悉:

从手指点击下去开始Down,在里面可以调用startNestedScroll(),告诉 Parent,Child准备进入滑动状态了,这时候Parent会被回调onStartNestedScroll(),如果这个方法返回true,代表它接受嵌套滑动,紧接着Parent会被回调onNestedScrollAccepted()来记录滑动的方向。

手指滑动Move,需要问一下Parent 是否需要滑动,即调用dispatchNestedPreScroll(),这时Parent会被回调onNestedPreScroll()。如果Parent要滑动,即消耗事件那么这个就会返回true,我们需要重新计算一下父类滑动后剩下给你的滑动距离,之后Child进行剩余的滑动,即调用overScrollByCompat,把滑动距离的参数传给mScroller进行弹性滑动,最后,如果滑动距离还有剩余,Child就再问一下,Parent 是否需要在继续滑动你剩下的距离,也就是调用dispatchNestedScroll()。这时候Child就会在滑动之后报告滑动的情况,即Parent的onNestedScroll会被调用。

手指离开Up,会调用dispatchNestedPreFling,从这里开始,分配fling和分配scroll的过程是一样的,fling也会有和scroll对应的方法。最后Child调用stopNestedScroll,然后Parent被调用onStopNestedScroll结束滑动。

到这里,滑动机制的大体过程就介绍完了,感兴趣的还可以去看看源码,然后看看Helper里的实现。

源码下载

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

发表评论

邮箱地址不会被公开。 必填项已用*标注