越来越多的应用中使用的嵌套滚动的效果,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)
基础知识介绍到此,我们来看看如何实现知乎日报的嵌套滚动效果吧,先看看知乎的效果:
我们分析一下这个界面的布局,可以分为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可以实现更复杂的滚动效果,本例中没有这么复杂,无需使用。
贴上最终实现效果图:
和知乎的效果还差2部分:
1、fling惯性滑动我还没有做
2、在Header上滑动时,知乎是可以滑动到,我的Demo无法滑动。maybe知乎是自定义view,我仿知乎日报时注意到这个页面是个WebView,顶部的Header是一个空白,也许是故意留白,然后叠上一个HeaderView?
最后放上链接:
github地址:https://github.com/zzjivan/NestedScrollDemo