图:四川九寨沟
本文来自徐俊的独家投稿,徐俊的blog地址为:博客地址:/gdutxiaoxu/,点击【阅读原文】,可查看本文链接。
使用CoordinatorLayout打造各种炫酷的效果
自定义Behavior —— 仿知乎,FloatActionButton隐藏与展示
NestedScrolling 机制深入解析
一步步带你读懂 CoordinatorLayout 源码
自定义 Behavior -仿新浪微博发现页的实现
效果图
我们先来看一下新浪微博发现页的效果:
接下来我们在来看一下我们仿照新浪微博实现的效果
实现思路分析
我们这里先定义两种状态,open 和 close 状态。
open 状态指 Tab+ViewPager 还没有滑动到顶部的时候,header 还 没有被完全移除屏幕的时候
close 状态指 Tab+ViewPager 滑动到顶部的时候,Header 被移除屏幕的时候
从效果图,我们可以看到 在 open 状态下,我们向上滑动 ViewPager 里面的 RecyclerView 的 时候,RecyclerView 并不会向上移动(RecyclerView 的滑动事件交给 外部的容器处理,被被全部消费掉了),而是整个布局(指 Header + Tab +ViewPager)会向上偏移。当 Tab 滑动到顶部的时候,我们向上滑动 ViewPager 里面的 RecyclerView 的时候,RecyclerView 可以正常向上滑动,即此时外部容器没有拦截滑动事件。
同时我们可以看到在 open 状态的时候,我们是不支持下拉刷新的,这个比较容易实现,监听页面的状态,如果是 open 状态,我们设置 SwipeRefreshLayout setEnabled 为 false,这样不会 拦截事件,在页面 close 的时候,设置 SwipeRefreshLayout setEnabled 为 TRUE,这样就可以支持下拉刷新了。
基于上面的分析,我们这里可以把整个效果划分为两个部分,第一部分为 Header,第二部分为 Tab+ViewPager。下文统一把第一部分称为 Header,第二部分称为 Content 。
需要实现的效果为:在页面状态为 open 的时候,向上滑动 Header 的时候,整体向上偏移,ViewPager 里面的 RecyclerView 向上滑动的时候,消费其滑动事件,并整体向上移动。在页面状态为 close 的时候,不消耗 RecyclerView 的 滑动事件。
在上一篇博客一步步带你读懂 CoordinatorLayout 源码中,我们有提到在 CoordinatorLayout中,我们可以通过 给子 View 自定义 Behavior 来处理事件。它是一个容器,实现了 NestedScrollingParent 接口。它并不会直接处理事件,而是会尽可能地交给子 View 的 Behavior 进行处理。因此,为了减少依赖,我们把这两部分的关系定义为 Content 依赖于 Header。Header 移动的时候,Content 跟着 移动。所以,我们在处理滑动事件的时候,只需要处理好 Header 部分的 Behavior 就oK了,Content 部分的 Behavior 不需要处理滑动事件,只需依赖于 Header ,跟着做相应的移动即可。
Header 部分的实现
Header 部分实现的两个关键点在于
在页面状态为 open 的时候,ViewPager 里面的 RecyclerView 向上滑动的时候,消费其滑动事件,并整体向上移动。在页面状态为 close 的时候,不消耗 RecyclerView 的 滑动事件
在页面状态为 open 的时候,向上滑动 Header 的时候,整体向上偏移。
第一个关键点的实现
这里区分页面状态是 open 还是 close 状态是通过 Header 是否移除屏幕来区分的,即 child.getTranslationY() == getHeaderOffsetRange() 。
private boolean isClosed(View child) {boolean isClosed = child.getTranslationY() == getHeaderOffsetRange();return isClosed;}
在NestedScrolling 机制深入解析博客中,我们对 NestedScrolling 机制做了如下的总结。
在 Action_Down 的时候,Scrolling child 会调用 startNestedScroll 方法,通过 childHelper 回调 Scrolling Parent 的 startNestedScroll 方法。
在 Action_move 的时候,Scrolling Child 要开始滑动的时候,会调用dispatchNestedPreScroll 方法,通过 ChildHelper 询问 Scrolling Parent 是否要先于 Child 进行 滑动,若需要的话,会调用 Parent 的 onNestedPreScroll 方法,协同 Child 一起进行滑动
当 ScrollingChild 滑动完成的时候,会调用 dispatchNestedScroll 方法,通过 ChildHelper 询问 Scrolling Parent 是否需要进行滑动,需要的话,会 调用 Parent 的 onNestedScroll 方法
在 Actiondown,Actionmove 的时候,会调用 Scrolling Child 的stopNestedScroll ,通过 ChildHelper 询问 Scrolling parent 的 stopNestedScroll 方法。
如果需要处理 Fling 动作,我们可以通过 VelocityTrackerCompat 获得相应的速度,并在 Action_up 的时候,调用 dispatchNestedPreFling 方法,通过 ChildHelper 询问 Parent 是否需要先于 child 进行 Fling 动作 在 Child 处理完 Fling 动作时候,如果 Scrolling Parent 还需要处理 Fling 动作,我们可以调用 dispatchNestedFling 方法,通过 ChildHelper ,调用 Parent 的 onNestedFling 方法
而 RecyclerView 也是 Scrolling Child (实现了 NestedScrollingChild 接口),RecyclerView 在开始滑动的 时候会先调用 CoordinatorLayout 的 startNestedScroll 方法,而 CoordinatorLayout 会 调用子 View 的 Behavior 的 startNestedScroll 方法。并且只有 boolean startNestedScroll 返回 TRUE 的 时候,才会调用接下里 Behavior 中的 onNestedPreScroll 和 onNestedScroll 方法。
所以,我们在 WeiboHeaderPagerBehavior 的 onStartNestedScroll 方法可以这样写,可以确保 只拦截垂直方向上的滚动事件,且当前状态是打开的并且还可以继续向上收缩的时候还会拦截
@Overridepublic boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, View child, ViewdirectTargetChild, View target, int nestedScrollAxes) {if (BuildConfig.DEBUG) {Log.d(TAG, "onStartNestedScroll: nestedScrollAxes=" + nestedScrollAxes);}boolean canScroll = canScroll(child, 0);//拦截垂直方向上的滚动事件且当前状态是打开的并且还可以继续向上收缩return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 && canScroll &&!isClosed(child);}
拦截事件之后,我们需要在 RecyclerView 滑动之前消耗事件,并且移动 Header,让其向上偏移。
@Overridepublic void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View child, View target,int dx, int dy, int[] consumed) {super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);//dy>0 scroll up;dy<0,scroll downLog.i(TAG, "onNestedPreScroll: dy=" + dy);float halfOfDis = dy;// 不能滑动了,直接给 Header 设置 终值,防止出错if (!canScroll(child, halfOfDis)) {child.setTranslationY(halfOfDis > 0 ? getHeaderOffsetRange() : 0);} else {child.setTranslationY(child.getTranslationY() - halfOfDis);}//consumed all scroll behavior after we started Nested Scrollingconsumed[1] = dy;}
当然,我们也需要处理 Fling 事件,在页面没有完全关闭的 时候,消费所有 fling 事件。
@Overridepublic boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, View child, View target,float velocityX, float velocityY) {// consumed the flinging behavior until Closedreturn !isClosed(child);}
至于滑动到顶部的动画,我是通过 mOverScroller + FlingRunnable 来实现的 。完整代码如下。
public class WeiboHeaderPagerBehavior extends ViewOffsetBehavior {private static final String TAG = "UcNewsHeaderPager";public static final int STATE_OPENED = 0;public static final int STATE_CLOSED = 1;public static final int DURATION_SHORT = 300;public static final int DURATION_LONG = 600;private int mCurState = STATE_OPENED;private OnPagerStateListener mPagerStateListener;private OverScroller mOverScroller;private WeakReference<CoordinatorLayout> mParent;private WeakReference<View> mChild;public void setPagerStateListener(OnPagerStateListener pagerStateListener) {mPagerStateListener = pagerStateListener;}public WeiboHeaderPagerBehavior() {init();}public WeiboHeaderPagerBehavior(Context context, AttributeSet attrs) {super(context, attrs);init();}private void init() {mOverScroller = new OverScroller(BaseAPP.getAppContext());}@Overrideprotected void layoutChild(CoordinatorLayout parent, View child, int layoutDirection) {super.layoutChild(parent, child, layoutDirection);mParent = new WeakReference<CoordinatorLayout>(parent);mChild = new WeakReference<View>(child);}@Overridepublic boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, View child, ViewdirectTargetChild, View target, int nestedScrollAxes) {if (BuildConfig.DEBUG) {Log.d(TAG, "onStartNestedScroll: nestedScrollAxes=" + nestedScrollAxes);}boolean canScroll = canScroll(child, 0);//拦截垂直方向上的滚动事件且当前状态是打开的并且还可以继续向上收缩return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 && canScroll &&!isClosed(child);}@Overridepublic boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, View child, View target,float velocityX, float velocityY) {// consumed the flinging behavior until Closedboolean coumsed = !isClosed(child);Log.i(TAG, "onNestedPreFling: coumsed=" +coumsed);return coumsed;}@Overridepublic boolean onNestedFling(CoordinatorLayout coordinatorLayout, View child, View target,float velocityX, float velocityY, boolean consumed) {Log.i(TAG, "onNestedFling: velocityY=" +velocityY);return super.onNestedFling(coordinatorLayout, child, target, velocityX, velocityY,consumed);}private boolean isClosed(View child) {boolean isClosed = child.getTranslationY() == getHeaderOffsetRange();return isClosed;}public boolean isClosed() {return mCurState == STATE_CLOSED;}private void changeState(int newState) {if (mCurState != newState) {mCurState = newState;if (mCurState == STATE_OPENED) {if (mPagerStateListener != null) {mPagerStateListener.onPagerOpened();}} else {if (mPagerStateListener != null) {mPagerStateListener.onPagerClosed();}}}}// 表示 Header TransLationY 的值是否达到我们指定的阀值, headerOffsetRange,到达了,返回 false,// 否则,返回 true。注意 TransLationY 是负数。private boolean canScroll(View child, float pendingDy) {int pendingTranslationY = (int) (child.getTranslationY() - pendingDy);int headerOffsetRange = getHeaderOffsetRange();if (pendingTranslationY >= headerOffsetRange && pendingTranslationY <= 0) {return true;}return false;}@Overridepublic boolean onInterceptTouchEvent(CoordinatorLayout parent, final View child, MotionEventev) {boolean closed = isClosed();Log.i(TAG, "onInterceptTouchEvent: closed=" + closed);if (ev.getAction() == MotionEvent.ACTION_UP && !closed) {handleActionUp(parent,child);}return super.onInterceptTouchEvent(parent, child, ev);}@Overridepublic void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View child, View target,int dx, int dy, int[] consumed) {super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);//dy>0 scroll up;dy<0,scroll downLog.i(TAG, "onNestedPreScroll: dy=" + dy);float halfOfDis = dy;// 不能滑动了,直接给 Header 设置 终值,防止出错if (!canScroll(child, halfOfDis)) {child.setTranslationY(halfOfDis > 0 ? getHeaderOffsetRange() : 0);} else {child.setTranslationY(child.getTranslationY() - halfOfDis);}//consumed all scroll behavior after we started Nested Scrollingconsumed[1] = dy;}// 需要注意的是 Header 我们是通过 setTranslationY 来移出屏幕的,所以这个值是负数private int getHeaderOffsetRange() {return BaseAPP.getInstance().getResources().getDimensionPixelOffset(R.dimen.weibo_header_offset);}private void handleActionUp(CoordinatorLayout parent, final View child) {if (BuildConfig.DEBUG) {Log.d(TAG, "handleActionUp: ");}if (mFlingRunnable != null) {child.removeCallbacks(mFlingRunnable);mFlingRunnable = null;}mFlingRunnable = new FlingRunnable(parent, child);if (child.getTranslationY() < getHeaderOffsetRange() / 6.0f) {mFlingRunnable.scrollToClosed(DURATION_SHORT);} else {mFlingRunnable.scrollToOpen(DURATION_SHORT);}}private void onFlingFinished(CoordinatorLayout coordinatorLayout, View layout) {changeState(isClosed(layout) ? STATE_CLOSED : STATE_OPENED);}public void openPager() {openPager(DURATION_LONG);}/*** @param duration open animation duration*/public void openPager(int duration) {View child = mChild.get();CoordinatorLayout parent = mParent.get();if (isClosed() && child != null) {if (mFlingRunnable != null) {child.removeCallbacks(mFlingRunnable);mFlingRunnable = null;}mFlingRunnable = new FlingRunnable(parent, child);mFlingRunnable.scrollToOpen(duration);}}public void closePager() {closePager(DURATION_LONG);}/*** @param duration close animation duration*/public void closePager(int duration) {View child = mChild.get();CoordinatorLayout parent = mParent.get();if (!isClosed()) {if (mFlingRunnable != null) {child.removeCallbacks(mFlingRunnable);mFlingRunnable = null;}mFlingRunnable = new FlingRunnable(parent, child);mFlingRunnable.scrollToClosed(duration);}}private FlingRunnable mFlingRunnable;/*** For animation , Why not use {@link android.view.ViewPropertyAnimator } to play animation* is of the* other {@link CoordinatorLayout.Behavior} that depend on this could not receiving the* correct result of* {@link View#getTranslationY()} after animation finished for whatever reason that i don't know*/private class FlingRunnable implements Runnable {private final CoordinatorLayout mParent;private final View mLayout;FlingRunnable(CoordinatorLayout parent, View layout) {mParent = parent;mLayout = layout;}public void scrollToClosed(int duration) {float curTranslationY = ViewCompat.getTranslationY(mLayout);float dy = getHeaderOffsetRange() - curTranslationY;if (BuildConfig.DEBUG) {Log.d(TAG, "scrollToClosed:offest:" + getHeaderOffsetRange());Log.d(TAG, "scrollToClosed: cur0:" + curTranslationY + ",end0:" + dy);Log.d(TAG, "scrollToClosed: cur:" + Math.round(curTranslationY) + ",end:" + Math.round(dy));Log.d(TAG, "scrollToClosed: cur1:" + (int) (curTranslationY) + ",end:" + (int) dy);}mOverScroller.startScroll(0, Math.round(curTranslationY - 0.1f), 0, Math.round(dy +0.1f), duration);start();}public void scrollToOpen(int duration) {float curTranslationY = ViewCompat.getTranslationY(mLayout);mOverScroller.startScroll(0, (int) curTranslationY, 0, (int) -curTranslationY,duration);start();}private void start() {if (puteScrollOffset()) {mFlingRunnable = new FlingRunnable(mParent, mLayout);ViewCompat.postOnAnimation(mLayout, mFlingRunnable);} else {onFlingFinished(mParent, mLayout);}}@Overridepublic void run() {if (mLayout != null && mOverScroller != null) {if (puteScrollOffset()) {if (BuildConfig.DEBUG) {Log.d(TAG, "run: " + mOverScroller.getCurrY());}ViewCompat.setTranslationY(mLayout, mOverScroller.getCurrY());ViewCompat.postOnAnimation(mLayout, this);} else {onFlingFinished(mParent, mLayout);}}}}/*** callback for HeaderPager 's state*/public interface OnPagerStateListener {/*** do callback when pager closed*/void onPagerClosed();/*** do callback when pager opened*/void onPagerOpened();}}
第二个关键点的实现
在页面状态为 open 的时候,向上滑动 Header 的时候,整体向上偏移。
在第一个关键点的实现上,我们是通过自定义 Behavior 来处理 ViewPager 里面 RecyclerView 的移动的,那我们要怎样监听整个 Header 的滑动了。
那就是重写 LinearLayout,将滑动事件交给 ScrollingParent(这里是CoordinatorLayout) 去处理,CoordinatorLayout 再交给子 View 的 behavior 去处理。
Content 部分的实现
Content 部分的实现也主要有两个关键点
整体置于 Header 之下
Content 跟着 Header 移动。即 Header 位置发生变化的时候,Content 也需要随着调整位置。
第一个关键点的实现
整体置于 Header 之下。这个我们可以参考 APPBarLayout 的 behavior,它是这样处理的。
这个基类的代码还是很好理解的,因为之前就说过了,正常来说被依赖的 View 会优先于依赖它的 View 处理,所以需要依赖的 View 可以在 measure/layout 的时候,找到依赖的 View 并获取到它的测量/布局的信息,这里的处理就是依靠着这种关系来实现的.
我们的实现类,需要重写的除了抽象方法 findFirstDependency 外,还需要重写 getScrollRange,我们把 Header 的 Id idweiboheader 定义在 ids.xml 资源文件内,方便依赖的判断.
至于缩放的高度,根据 结果图 得知是 0,得出如下代码
private int getFinalHeight() {Resources resources = BaseAPP.getInstance().getResources();return 0;}@Overrideprotected int getScrollRange(View v) {if (isDependOn(v)) {return Math.max(0, v.getMeasuredHeight() - getFinalHeight());} else {return super.getScrollRange(v);}}
第二个关键点的实现:
Content 跟着 Header 移动。即 Header 位置发生变化的时候,Content 也需要随着调整位置。
主要的逻辑就是 在 layoutDependsOn 方法里面,判断 dependcy 是不是 HeaderView ,是的话,返回TRUE,这样在 Header 位置发生变化的时候,会回调 onDependentViewChanged 方法,在该方法里面,做相应的偏移。TranslationY 是根据比例算出来的 translationY = (int) (-dependencyTranslationY / (getHeaderOffsetRange() * 1.0f) * getScrollRange(dependency));
完整代码如下:
public class WeiboContentBehavior extends HeaderScrollingViewBehavior {private static final String TAG = "WeiboContentBehavior";public WeiboContentBehavior() {}public WeiboContentBehavior(Context context, AttributeSet attrs) {super(context, attrs);}@Overridepublic boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {return isDependOn(dependency);}@Overridepublic boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {if (BuildConfig.DEBUG) {Log.d(TAG, "onDependentViewChanged");}offsetChildAsNeeded(parent, child, dependency);return false;}private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {float dependencyTranslationY = dependency.getTranslationY();int translationY = (int) (-dependencyTranslationY / (getHeaderOffsetRange() * 1.0f) * getScrollRange(dependency));Log.i(TAG, "offsetChildAsNeeded: translationY=" + translationY);child.setTranslationY(translationY);}@Overrideprotected View findFirstDependency(List<View> views) {for (int i = 0, z = views.size(); i < z; i++) {View view = views.get(i);if (isDependOn(view)) return view;}return null;}@Overrideprotected int getScrollRange(View v) {if (isDependOn(v)) {return Math.max(0, v.getMeasuredHeight() - getFinalHeight());} else {return super.getScrollRange(v);}}private int getHeaderOffsetRange() {return BaseAPP.getInstance().getResources().getDimensionPixelOffset(R.dimen.weibo_header_offset);}private int getFinalHeight() {Resources resources = BaseAPP.getInstance().getResources();return 0;}private boolean isDependOn(View dependency) {return dependency != null && dependency.getId() == R.id.id_weibo_header;}}
题外话
NestedScrolling 机制,对比传统的事件分发机制真的很强大。这种仿新浪微博发现页效果, 如果用传统的事件分发机制来做,估计很难实现,处理起来会有一大堆坑。
看完了这种仿新浪微博发现页的效果,你是不是学到了什么?如果让你 模仿 仿 QQ 浏览器首页效果,你能实现话。
最后,特别感谢写这篇博客自定义Behavior的艺术探索-仿UC浏览器主页的开发者,没有这篇博客作为参考,这种效果我很大几率是实现 不了的。大家觉得效果还不错的话,顺手到 github 上面给我 star,谢谢。github 地址:/gdutxiaoxu/CoordinatorLayoutExample
参考文章:
自定义Behavior的艺术探索-仿UC浏览器主页
第一时间获得【不止个人原创 android/音视频技术干货,问题深度总结,FrameWork源码解析,插件化研究,FFmpeg研究,直播技术,最新开源项目推荐,还有更多职场思考】,欢迎关注我的微信公众号,扫一扫下方二维码或者长按识别二维码