Behavior应用--仿知乎日报嵌套滚动效果

越来越多的应用中使用的嵌套滚动的效果,Google也在Material Design中加入了原生支持,CoordinatorLayout、AppbarLayout等控件也能让我们很方便的实现一些嵌套滚动效果。但是碰到自定义需求时,我们还是需要弄懂CoordinatorLayout这些控件的原理,在此基础上进行自定义。

Google提供的这套嵌套滚动方案是基于NestedScrollingParent和NestedScrollingChild这两个接口实现的,像CoordinatorLayout就是实现了NestedScrollingPartent接口。

当我们实现嵌套滚动效果时,我们有2种办法:
1、自定义view实现NestedScrollingChild或者NestedScrollingParent接口;
2、使用原生控件,对原生控件设置Behavior;
同一个效果,这两种方法都是可以实现的,看你具体的应用场景,自定选择,本文讲解的是Behavior实现方式。

Behavior是一个抽象类,在我们自定义时,主要关注如下几个重要方法:

//事件分发拦截
onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev)

//child:使用该behavior的view
//dependency:会遍历查找,你只用在这里判断本次传入的dependency是否符合自己需求,以此返回true or false
layoutDependsOn(CoordinatorLayout parent, V child, View dependency)

//dependency状态发生变化时,会回调此方法,在这里可以对child进行操作,实现同步更新的效果。
onDependentViewChanged(CoordinatorLayout parent, V child, View dependency)

//在此写入自己的逻辑,可以对child进行重布局
onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection)

//在滑动开始之前,调动此方法。我们实现自己的逻辑设置返回结果(true or false)来决定是否要进行嵌套滑动:当返回值为true的时候表明CoordinatorLayout 充当NestedScrollingPartent处理这次滑动;若返回false,后续回调将不会触发,也就无法执行嵌套滚动了。
onStartNestedScroll(CoordinatorLayout coordinatorLayout, V child, View directTargetChild, View target, int nestedScrollAxes)

//onStartNestedScroll返回true才会回调该方法,参数和onStartNestedScroll一样,嗯,不知道为什么分开。。可以做些初始化工作吧?
onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, V child, View directTargetChild, View target, int nestedScrollAxes)

//嵌套滚动前回调该方法。dx,dy分别是x、y方向上单次滑动(ACTION_DOWN  ACTION_UP之间产生)的距离;consumed数组存放的是child在本次滑动中消耗掉的距离,数据0,1元素分别对应x,y;dy-consumed[1]就是y轴没有被消耗的距离,这个距离会在后续仍然交由触发滑动事件的view来消费,x轴同理。如RecyclerView滑动100,执行嵌套滑动,child消费40,那么RecyclerView将滑动60.
onNestedPreScroll(CoordinatorLayout coordinatorLayout, V child, View target, int dx, int dy, int[] consumed)

//嵌套滚动时调用
onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed)

//嵌套滚动完成后调用,进行一些资源释放回收操作
onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target)

//惯性滚动前调动,依据返回结果决定是否消费惯性滚动
onNestedPreFling(CoordinatorLayout coordinatorLayout, V child, View target, float velocityX, float velocityY)

//惯性滚动回调
onNestedFling(CoordinatorLayout coordinatorLayout, V child, View target, float velocityX, float velocityY, boolean consumed)

基础知识介绍到此,我们来看看如何实现知乎日报的嵌套滚动效果吧,先看看知乎的效果:

zhihu

我们分析一下这个界面的布局,可以分为3部分:
顶部的Toolbar
中间的图片标题Header
底部的新闻内容Content

再来看看滚动过程,可以分为2步:
1、Header和Content向上滚动,其中Header滚动速度较慢;Content的顶部到达Toolbar底部时,Toolbar正好透明;
2、Header和Content继续向上滚动,最终Header被Content覆盖。

Content移动的距离为Toolbar的高度 + Header的高度;
Header的滑动速率一直是小于Content,因此Header的滑动距离肯定是小于Content的,他们的速率差其实就是体现在最终滑动距离的差异,因此可以按照百分比来确定Header滑动的距离。(百分比数值影响速率差)

上文介绍Behavior是有dependency这个概念,这个dependency是可以自己对要实现效果的理解来灵活选取的。在这个例子中,我是如下选择的:
因为Header总共移动距离header_y是可以预先计算的,设计Content依赖于Header滑动,当Header滑动时,Content的滑动距离都是可以对应计算的。

Toolbar前半段是改变透明度,后半段是移动,这都需要知道Content的状态,因此选取其dependency为Content。

具体实现,布局:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:apps="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/iv_header"
        android:layout_width="match_parent"
        android:layout_height="@dimen/news_header_pager_height"
        android:layout_marginTop="@dimen/news_tool_bar_height"
        android:background="@drawable/pic1"
        apps:layout_behavior="com.snick.zzj.myapplication.HeaderViewBehavior"/>

    <!-- AppBarLayout内部实现了NestedScrollingChild接口-->
    <FrameLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/news_tool_bar_height"
        apps:layout_behavior="com.snick.zzj.myapplication.ToolbarBehavior">
        <!-- Toolbar没有实现NestedScrollingChild接口,因此必须外层嵌套AppBarLayout才能实现嵌套滚动效果-->
        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="@dimen/news_tool_bar_height"
            android:minHeight="?attr/actionBarSize"
            android:background="@color/colorPrimary">
        </android.support.v7.widget.Toolbar>

    </FrameLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        apps:layout_behavior="com.snick.zzj.myapplication.ContentBehavior">
    </android.support.v7.widget.RecyclerView>

</android.support.design.widget.CoordinatorLayout>

我们先来实现HeaderViewBehavior,先贴代码:

public class HeaderViewBehavior extends CoordinatorLayout.Behavior<ImageView> {
    private static final String TAG = "HeaderViewBehavior";

    private Context context;

    //一定要实现这个构造函数,否则会报Could not inflate Behavior subclass xxx 异常,可查看源码
    public HeaderViewBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
    }

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, ImageView child, View directTargetChild, View target, int nestedScrollAxes) {
        boolean result = canScroll(child, 0);
        return result;
    }

    //getTranslationY计算的是view对于parent的偏移量
    private boolean canScroll(View child, float pendingDy) {
        int pendingTranslationY = (int) (child.getTranslationY() - pendingDy);
        Log.d(TAG, "canScroll:"+pendingTranslationY+"------"+getHeaderOffsetRange()+"-------"+getHeaderOffsetRangeHideToolBar());
        if (pendingTranslationY >= 0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar() && pendingTranslationY <= 0) {
            return true;
        }
        return false;
    }

    //onNestedPreScroll该方法的会传入内部View移动的dx,dy,如果你需要消耗一定的dx,dy,
    // 就通过最后一个参数consumed进行指定,例如我要消耗一半的dy,就可以写consumed[1]=dy/2
    //dy是单次滑动的距离,下次滑动会重新计数
    @Override
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, ImageView child, View target, int dx, int dy, int[] consumed) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
        //dy>0 scroll up;dy<0,scroll down
        float halfOfDis = dy / 4.0f; //消费掉其中的4分之1,不至于滑动效果太灵敏
        //在快速滑动时halfOfDis有可能一次跳变超过20以上,如果原本translationY差19到达顶部,这样一来就会判断成无法scroll,造成顶部有缝隙
        if (canScroll(child, halfOfDis)) {
            child.setTranslationY(child.getTranslationY() - halfOfDis);
        } else if(halfOfDis > Math.abs(child.getTranslationY() + getHeaderOffsetRange() + getHeaderOffsetRangeHideToolBar())) {
            Log.d(TAG,"direct to top");
            child.setTranslationY(0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar());
        } else if(halfOfDis < child.getTranslationY()) {
            child.setTranslationY(0);
        }
        //当滑动到顶部时,继续往上滑应该是不允许滑动,但是向下应该是可以滑动
        //但是我们在onStartNestedScroll中没法判断滑动的方向,因此只好在这里判断了。

        Log.d(TAG,"Y:"+child.getTranslationY());
        if((dy>0&&child.getTranslationY() == 0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar()) ||
                (dy<0&&child.getTranslationY() == 0))
            consumed[1] = 0;
        else
            consumed[1] = dy;
    }

    //Header偏移量
    private int getHeaderOffsetRange() {
        return context.getResources().getDimensionPixelOffset(R.dimen.header_offset_first);
    }

    //为了保证Header滑动速率保持一直,第二段的Header移动距离我们计算出来。
    //第一段移动:header移动了header_offset_first的距离,content移动了news_header_pager_height的距离,toolbar透明了
    //第二段移动:content移动news_tool_bar_height的距离,toolbar也移动news_tool_bar_height距离,header的距离是可以比例计算的。
    private int getHeaderOffsetRangeHideToolBar() {
        return getHeaderOffsetRange() * context.getResources().getDimensionPixelOffset(R.dimen.news_tool_bar_height)
                / context.getResources().getDimensionPixelOffset(R.dimen.news_header_pager_height);
    }
}

在canScroll方法中处理是否处理嵌套滑动的逻辑;
onNestedPreScroll方法中处理嵌套滑动的逻辑,这里有2个注意点:
1、在滑动到顶部或者底部时,可能会有最后一组滑动数据无法触发(可看看代码中的注释),我们单独判断滑动距离是否超越了顶部或底部边界,然后直接setTranslationY到顶部或底部;
2、在此例中,Content滑动到顶部时,是不能向上滑,但是可以向下滑,因此在canScroll中需要知道滑动方向,这在onStartNestedScroll中是无法获取的,因此我放在了onNestedPreScroll中处理:当到达顶部,向上滑时,child并不处理,但是通过consumed数组将该次滑动数据“丢弃”。

再来实现ContentViewBehavior,代码如下:

public class ContentBehavior extends CoordinatorLayout.Behavior<RecyclerView> {
    private static final String TAG = "ContentBehavior";

    private Context context;

    public ContentBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
        return isDependOn(dependency);
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency) {
        //初始设置,content要在header之下,因此初始需要有一个Y的偏移
        if(dependency.getTranslationY() == 0)
            child.setTranslationY(getContentInitOffset());
        else {
            child.setTranslationY(getContentInitOffset() +
                    dependency.getTranslationY() * dependency.getHeight() / getHeaderOffsetRange());
        }
        return false;
    }

    private boolean isDependOn(View dependency) {
        return dependency != null && dependency.getId() == R.id.iv_header;
    }

    //Header偏移量
    private int getHeaderOffsetRange() {
        return context.getResources().getDimensionPixelOffset(R.dimen.header_offset_first);
    }

    private int getContentInitOffset() {
        return context.getResources().getDimensionPixelOffset(R.dimen.content_offset_init);
    }

    //为了保证Header滑动速率保持一直,第二段的Header移动距离我们计算出来。
    //第一段移动:header移动了header_offset_first的距离,content移动了news_header_pager_height的距离,toolbar透明了
    //第二段移动:content移动news_tool_bar_height的距离,toolbar也移动news_tool_bar_height距离,header的距离是可以比例计算的。
    private int getHeaderOffsetRangeHideToolBar() {
        return getHeaderOffsetRange() * context.getResources().getDimensionPixelOffset(R.dimen.news_tool_bar_height)
                / context.getResources().getDimensionPixelOffset(R.dimen.news_header_pager_height);
    }
}

主要就是判断滑动边界,决定是否进行嵌套滑动;然后获取滑动距离,自己决定消耗多少。

Toolbar的代码就不贴了,和Content的类似,比较简单。

总结下:
有dependency的Behavior实现比较简单,跟随dependency的移动做变化就好了;
需要根据滑动状态来进行滑动的,需要重写onStartNestedScroll和onNestedPreScroll等方法,比较复杂
同时使用dependency和onNestedPreScroll可以实现更复杂的滚动效果,本例中没有这么复杂,无需使用。

贴上最终实现效果图:

Demo GIF

和知乎的效果还差2部分:
1、fling惯性滑动我还没有做
2、在Header上滑动时,知乎是可以滑动到,我的Demo无法滑动。maybe知乎是自定义view,我仿知乎日报时注意到这个页面是个WebView,顶部的Header是一个空白,也许是故意留白,然后叠上一个HeaderView?

最后放上链接:
github地址:https://github.com/zzjivan/NestedScrollDemo

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值