引言
在上一节Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果 中,我们通过自定义View的形式完成了TabBar的吸顶效果,其实除了这种方式之外,MD控件中提供了一个CoordinatorLayout,协调者布局,这种布局同样可以实现吸顶效果,但是很多伙伴们对于CoordinatorLayout有点儿陌生,或者认为它用起来比较麻烦,其实大多数原因是因为对于它的原理不太熟悉,不知道什么时候该用什么样的组件或者behavior,所以首先了解它的原理,就能够对CoordinatorLayout驾轻就熟。
1 CoordinatorLayout功能介绍
首先我们先从源码中能够看到,CoordinatorLayout只实现了parent接口(这里如果不清楚parent接口是干什么的,建议看看前面的文章,不然根本不清楚我讲的是什么),说明CoordinatorLayout只能作为父容器来使用。
public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2, NestedScrollingParent3
所以对于CoordinatorLayout来说,它的主要作用就是用来管理子View或者子View之间的联动交互。所以在上一篇文章中,我们介绍的NestScroll嵌套滑动机制,它其实能够实现child与parent的嵌套滑动,但是是1对1的;而CoordinatorLayout是能够管理子View之间的交互,属于1对多的。
那么CoordinatorLayout能够实现哪些功能呢?
(1)子控件之间的交互依赖;
(2)子控件之间的嵌套滑动;
(3)子控件宽高的测量;
(4)子控件事件拦截与响应;
那么以上所有的功能实现,全部都是依赖于CoordinatorLayout中提供的一个Behavior插件。CoordinatorLayout将所有的事件交互都扔给了Behavior,目的就是为了解耦;这样就不需要在父容器中做太多的业务逻辑,而是通过不同的Behavior控制子View产生不同的行为。
1.1 CoordinatorLayout的依赖交互原理
首先我们先看第一个功能,处理子控件之间的依赖交互,这种处理方式其实在很多地方我们都能看到,例如一些小的悬浮窗,你可以拖动它到任何地方,点击让其消失的时候,跟随这个View的其他View也会一并消失。
那么如何使用CoordinatorLayout来实现这个功能呢?首先我们先看一下CoordinatorLayout处理这种事件的原理。
看一下上面的图,在协调者布局中,有3个子View:dependcy、child1、child2;当dependcy的发生位移或者消失的时候,那么CoordinatorLayout会通知所有与dependcy依赖的控件,并且调用他们内部声明的Behavior,告知其依赖的dependcy发生变化了。
那么如何判断依赖哪个控件,CoordinatorLayout-Behavior提供一个方法:layoutDependsOn,接收到的通知是什么样的呢?onDependentViewChanged / onDependentViewRemoved 分别代表依赖的View位置发生了变化和依赖的View被移除,这些都会交给Behavior来处理。
1.2 CoordinatorLayout的嵌套滑动原理
这部分其实还是挺简单的,如果有上一篇文章的基础,那么对于嵌套滑动就非常熟悉了
因为我们前面说过, CoordinatorLayout只能作为父容器,因为只实现了parent接口,所以在CoordinatorLayout内部需要有一个child,那么当child滑动时,首先会把实现传递给父容器,也就是CoordinatorLayout,再由CoordinatorLayout分发给每个child的Behavior,由Behavior来完成子控件的嵌套滑动。
这里有个问题,每个child都一定是CoordinatorLayout的直接子View吗?
剩下的两个功能就比较简单了,同样也是在Behavior中进行处理,就不做介绍了。
2 CoordinatorLayout源码分析
首先这里先跟大家说一下,在看源码的时候,我们最好依托于一个实例的实现,从而带着问题去源码中寻找答案,例如我们在第一节中提到过的CoordinatorLayout的四大功能,可能都会有这些问题:
(1)e.g. 控件之间的交互依赖,为什么在一个child下设置一个Behavior,就能够跟随DependentView的位置变化一起变化,他们是如何做依赖通信的?
(2)我们在XML中设置Behavior,是在什么时候实例化的?
(3)我们既然使用了CoordinatorLayout布局,那么内部是如何区分谁依赖谁呢?依赖关系是如何确定的?
(4)什么时候需要重新 onMeasureChild?什么时候需要重新onLayoutChild?
(5)每个设置Behavior的子View,一定要是CoordinatorLayout的直接子View吗?
那么带着这些问题,我们通过源码来得到答案。
2.1 CoordinatorLayout的依赖交互实现
如果要实现依赖交互效果,首先需要两个角色,分别是:DependentView和子View
class DependentView @JvmOverloads constructor( val mContext: Context, val attributeSet: AttributeSet? = null, val flag: Int = 0 ) : View(mContext, attributeSet, flag) { private var paint: Paint private var mStartX = 0 private var mStartY = 0 init { paint = Paint() paint.color = Color.parseColor("#000000") paint.style = Paint.Style.FILL isClickable = true } override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) canvas?.let { it.drawRect( Rect().apply { left = 200 top = 200 right = 400 bottom = 400 }, paint ) } } override fun onTouchEvent(event: MotionEvent?): Boolean { when (event?.action) { MotionEvent.ACTION_DOWN -> { Log.e("TAG","ACTION_DOWN") mStartX = event.rawX.toInt() mStartY = event.rawY.toInt() } MotionEvent.ACTION_MOVE -> { Log.e("TAG","ACTION_MOVE") val endX = event.rawX.toInt() val endY = event.rawY.toInt() val dx = endX - mStartX val dy = endY - mStartY ViewCompat.offsetTopAndBottom(this, dy) ViewCompat.offsetLeftAndRight(this, dx) postInvalidate() mStartX = endX mStartY = endY } } return super.onTouchEvent(event) } }
这里写了一个很简单的View,能够跟随手指滑动并一起移动,然后我们在当前View下加一个TextView,并让这个TextView跟着DependentView一起滑动。
class DependBehavior @JvmOverloads constructor(context: Context, attributeSet: AttributeSet) : CoordinatorLayout.Behavior(context, attributeSet) { override fun layoutDependsOn( parent: CoordinatorLayout, child: View, dependency: View ): Boolean { return dependency is DependentView } override fun onDependentViewChanged( parent: CoordinatorLayout, child: View, dependency: View ): Boolean { //获取dependency的位置 child.x = dependency.x child.y = dependency.bottom.toFloat() return true } }
如果想要达到随手的效果,那么就需要给TextView设置一个Behavior,上面我们定义了一个Behavior,它的主要作用就是,当DependentView滑动的时候,通过CoordinatorLayout来通知所有的DependBehavior修饰的View。
在DependBehavior中,我们看主要有两个方法:layoutDependsOn和onDependentViewChanged,这两个方法之前在原理中提到过,layoutDependsOn主要是用来决定依赖关系,看child依赖的是不是DependentView;如果依赖的是DependentView,那么在DependentView滑动的时候,就会通过回调onDependentViewChanged,告知子View当前dependency的位置信息,从而完成联动。
2.2 CoordinatorLayout交互依赖的源码分析
那么接下来,我们看下CoordinatorLayout是如何实现这个效果的。
在看CoordinatorLayout源码之前,我们首先需要知道View的生命周期,我们知道在onCreate的时候通过setContentView设置布局文件,如下所示:
如果我们熟悉setContentView的源码,系统是通过Inflate的方式解析布局文件,然后在onResume的时候显示布局,然后随之会调用onAttachedToWindow将布局显示在Window上,我们看下onAttachedToWindow这个方法。
@Override public void onAttachedToWindow() { super.onAttachedToWindow(); resetTouchBehaviors(false); if (mNeedsPreDrawListener) { if (mOnPreDrawListener == null) { mOnPreDrawListener = new OnPreDrawListener(); } final ViewTreeObserver vto = getViewTreeObserver(); vto.addOnPreDrawListener(mOnPreDrawListener); } if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) { // We're set to fitSystemWindows but we haven't had any insets yet... // We should request a new dispatch of window insets ViewCompat.requestApplyInsets(this); } mIsAttachedToWindow = true; }
在这个方法中,设置了addOnPreDrawListener监听,此监听在页面发生变化(滑动、旋转、重新获取焦点)会产生回调;
class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener { @Override public boolean onPreDraw() { onChildViewsChanged(EVENT_PRE_DRAW); return true; } }
final void onChildViewsChanged(@DispatchChangeEvent final int type) { final int layoutDirection = ViewCompat.getLayoutDirection(this); final int childCount = mDependencySortedChildren.size(); final Rect inset = acquireTempRect(); final Rect drawRect = acquireTempRect(); final Rect lastDrawRect = acquireTempRect(); for (int i = 0; i < childCount; i++) { final View child = mDependencySortedChildren.get(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (type == EVENT_PRE_DRAW && child.getVisibility() == View.GONE) { // Do not try to update GONE child views in pre draw updates. continue; } // Update any behavior-dependent views for the change for (int j = i + 1; j < childCount; j++) { final View checkChild = mDependencySortedChildren.get(j); final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams(); final Behavior b = checkLp.getBehavior(); if (b != null && b.layoutDependsOn(this, checkChild, child)) { if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) { // If this is from a pre-draw and we have already been changed // from a nested scroll, skip the dispatch and reset the flag checkLp.resetChangedAfterNestedScroll(); continue; } final boolean handled; switch (type) { case EVENT_VIEW_REMOVED: // EVENT_VIEW_REMOVED means that we need to dispatch // onDependentViewRemoved() instead b.onDependentViewRemoved(this, checkChild, child); handled = true; break; default: // Otherwise we dispatch onDependentViewChanged() handled = b.onDependentViewChanged(this, checkChild, child); break; } if (type == EVENT_NESTED_SCROLL) { // If this is from a nested scroll, set the flag so that we may skip // any resulting onPreDraw dispatch (if needed) checkLp.setChangedAfterNestedScroll(handled); } } } } releaseTempRect(inset); releaseTempRect(drawRect); releaseTempRect(lastDrawRect); }
在onChildViewsChanged这个方法中,我们看到有两个for循环,从mDependencySortedChildren中取出元素,首先我们先不需要关心mDependencySortedChildren这个数组,这个双循环的目的就是用来判断View之间是否存在绑定关系。
首先我们看下第二个循环,当拿到LayoutParams中的Behavior之后,就会调用Behavior的layoutDependsOn方法,假设此时child为DependentView,checkChild为TextView;
for (int j = i + 1; j < childCount; j++) { final View checkChild = mDependencySortedChildren.get(j); final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams(); final Behavior b = checkLp.getBehavior(); if (b != null && b.layoutDependsOn(this, checkChild, child)) { if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) { // If this is from a pre-draw and we have already been changed // from a nested scroll, skip the dispatch and reset the flag checkLp.resetChangedAfterNestedScroll(); continue; } final boolean handled; switch (type) { case EVENT_VIEW_REMOVED: // EVENT_VIEW_REMOVED means that we need to dispatch // onDependentViewRemoved() instead b.onDependentViewRemoved(this, checkChild, child); handled = true; break; default: // Otherwise we dispatch onDependentViewChanged() handled = b.onDependentViewChanged(this, checkChild, child); break; } if (type == EVENT_NESTED_SCROLL) { // If this is from a nested scroll, set the flag so that we may skip // any resulting onPreDraw dispatch (if needed) checkLp.setChangedAfterNestedScroll(handled); } } }
从上面的布局文件中看,TextView的Behavior中,layoutDependsOn返回的就是true,那么此时可以进入到代码块中,这里会判断type类型:EVENT_VIEW_REMOVED和其他type,因为此时的type不是REMOVE,所以就会调用BeHavior的onDependentViewChanged方法。
因为在onAttachedToWindow中,对View树中所有的元素都设置了OnPreDrawListener的监听,所以只要某个View发生了变化,都会走到onChildViewsChanged方法中,进行相应的Behavior检查并实现联动。
所以第2节开头的第一个问题,当DependentView发生位置变化时,是如何通信到child中的,这里就是通过设置了onPreDrawListener来监听。
第二个问题,Behavior是如何被初始化的?如果自定义过XML属性,那么大概就能了解,一般都是在布局初始化的时候,拿到layout_behavior属性初始化,我们看下源码。
if (mBehaviorResolved) { mBehavior = parseBehavior(context, attrs, a.getString( R.styleable.CoordinatorLayout_Layout_layout_behavior)); }
static Behavior parseBehavior(Context context, AttributeSet attrs, String name) { if (TextUtils.isEmpty(name)) { return null; } final String fullName; if (name.startsWith(".")) { // Relative to the app package. Prepend the app package name. fullName = context.getPackageName() + name; } else if (name.indexOf('.') >= 0) { // Fully qualified package name. fullName = name; } else { // Assume stock behavior in this package (if we have one) fullName = !TextUtils.isEmpty(WIDGET_PACKAGE_NAME) ? (WIDGET_PACKAGE_NAME + '.' + name) : name; } try { Map> constructors = sConstructors.get(); if (constructors == null) { constructors = new HashMap<>(); sConstructors.set(constructors); } Constructor c = constructors.get(fullName); if (c == null) { final Class clazz = (Class ) Class.forName(fullName, false, context.getClassLoader()); c = clazz.getConstructor(CONSTRUCTOR_PARAMS); c.setAccessible(true); constructors.put(fullName, c); } return c.newInstance(context, attrs); } catch (Exception e) { throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e); } }
通过源码我们可以看到,拿到全类名之后,通过反射的方式来创建Behavior,这里需要注意一点,在自定义Behavior的时候,需要两个构造参数CONSTRUCTOR_PARAMS,否则在创建Behavior的时候会报错,因为在反射创建Behavior的时候需要获取这两个构造参数。
static final Class>[] CONSTRUCTOR_PARAMS = new Class>[] { Context.class, AttributeSet.class };
报错类型就是:
Could not inflate Behavior subclass com.lay.learn.asm.behavior.DependBehavior
2.3 CoordinatorLayout子控件拦截事件源码分析
其实只要了解了其中一个功能的原理之后,其他功能都是类似的。对于CoordinatorLayout中的子View拦截事件,我们可以先看看CoordinatorLayout中的onInterceptTouchEvent方法。
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { final int action = ev.getActionMasked(); // Make sure we reset in case we had missed a previous important event. if (action == MotionEvent.ACTION_DOWN) { resetTouchBehaviors(true); } final boolean intercepted = performIntercept(ev, TYPE_ON_INTERCEPT); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { resetTouchBehaviors(true); } return intercepted; }
其中有一个核心方法performIntercept方法,这个方法中我们可以看到,同样也是拿到了Behavior的onInterceptTouchEvent方法,来优先判断子View是否需要拦截这个事件,如果不拦截,那么交给父容器消费,当前一般Behavior中也不会拦截。
private boolean performIntercept(MotionEvent ev, final int type) { boolean intercepted = false; boolean newBlock = false; MotionEvent cancelEvent = null; final int action = ev.getActionMasked(); final ListtopmostChildList = mTempList1; getTopSortedChildren(topmostChildList); // Let topmost child views inspect first final int childCount = topmostChildList.size(); for (int i = 0; i < childCount; i++) { final View child = topmostChildList.get(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); final Behavior b = lp.getBehavior(); if ((intercepted || newBlock) && action != MotionEvent.ACTION_DOWN) { // Cancel all behaviors beneath the one that intercepted. // If the event is "down" then we don't have anything to cancel yet. if (b != null) { if (cancelEvent == null) { final long now = SystemClock.uptimeMillis(); cancelEvent = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0); } switch (type) { case TYPE_ON_INTERCEPT: b.onInterceptTouchEvent(this, child, cancelEvent); break; case TYPE_ON_TOUCH: b.onTouchEvent(this, child, cancelEvent); break; } } continue; } if (!intercepted && b != null) { switch (type) { case TYPE_ON_INTERCEPT: intercepted = b.onInterceptTouchEvent(this, child, ev); break; case TYPE_ON_TOUCH: intercepted = b.onTouchEvent(this, child, ev); break; } if (intercepted) { mBehaviorTouchView = child; } } // Don't keep going if we're not allowing interaction below this. // Setting newBlock will make sure we cancel the rest of the behaviors. final boolean wasBlocking = lp.didBlockInteraction(); final boolean isBlocking = lp.isBlockingInteractionBelow(this, child); newBlock = isBlocking && !wasBlocking; if (isBlocking && !newBlock) { // Stop here since we don't have anything more to cancel - we already did // when the behavior first started blocking things below this point. break; } } topmostChildList.clear(); return intercepted; }
2.4 CoordinatorLayout嵌套滑动原理分析
对于嵌套滑动,其实在上一篇文章中已经介绍的很清楚了,加上CoordinatorLayout自身的特性,我们知道当子View(指的是实现了nestscrollchild接口的View)嵌套滑动的时候,那么首先会将事件向上分发到CoordinatorLayout中,所以在parent中的onNestedPreScroll的方法中会拿到回调。
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) { int xConsumed = 0; int yConsumed = 0; boolean accepted = false; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View view = getChildAt(i); if (view.getVisibility() == GONE) { // If the child is GONE, skip... continue; } final LayoutParams lp = (LayoutParams) view.getLayoutParams(); if (!lp.isNestedScrollAccepted(type)) { continue; } final Behavior viewBehavior = lp.getBehavior(); if (viewBehavior != null) { mBehaviorConsumed[0] = 0; mBehaviorConsumed[1] = 0; viewBehavior.onNestedPreScroll(this, view, target, dx, dy, mBehaviorConsumed, type); xConsumed = dx > 0 ? Math.max(xConsumed, mBehaviorConsumed[0]) : Math.min(xConsumed, mBehaviorConsumed[0]); yConsumed = dy > 0 ? Math.max(yConsumed, mBehaviorConsumed[1]) : Math.min(yConsumed, mBehaviorConsumed[1]); accepted = true; } } consumed[0] = xConsumed; consumed[1] = yConsumed; if (accepted) { onChildViewsChanged(EVENT_NESTED_SCROLL); } }
我们详细看下这个方法,对于parent的onNestedPreScroll方法,当然也是会获取到Behavior,这里也是拿到了子View的Behavior之后,调用其onNestedPreScroll方法,会把手指滑动的距离传递到子View的Behavior中。
所以这里我们先定义一个Behavior,这个Behavior是用来接收滑动事件分发的。当手指向上滑动的时候,首先将TextView隐藏,然后才能滑动RecyclerView。
class ScrollBehavior @JvmOverloads constructor( val mContext: Context, val attributeSet: AttributeSet ) : CoordinatorLayout.Behavior(mContext, attributeSet) { //相对于y轴滑动的距离 private var mScrollY = 0 //总共滑动的距离 private var totalScroll = 0 override fun onLayoutChild( parent: CoordinatorLayout, child: TextView, layoutDirection: Int ): Boolean { Log.e("TAG", "onLayoutChild----") //实时测量 parent.onLayoutChild(child, layoutDirection) return true } override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, child: TextView, directTargetChild: View, target: View, axes: Int, type: Int ): Boolean { //目的为了dispatch成功 return true } override fun onNestedPreScroll( coordinatorLayout: CoordinatorLayout, child: TextView, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int ) { //边界处理 var cosumedy = dy Log.e("TAG","onNestedPreScroll $totalScroll dy $dy") var scroll = totalScroll + dy if (abs(scroll) > getMaxScroll(child)) { cosumedy = getMaxScroll(child) - abs(totalScroll) } else if (scroll < 0) { cosumedy = 0 } //在这里进行事件消费,我们只需要关心竖向滑动 ViewCompat.offsetTopAndBottom(child, -cosumedy) //重新赋值 totalScroll += cosumedy consumed[1] = cosumedy } private fun getMaxScroll(child: TextView): Int { return child.height } }
对应的布局文件,区别在于TextView设置了ScrollBehavior。
当滚动RecyclerView的时候,因为RecyclerView属于nestscrollchild,所以事件先被传递到了CoordinatorLayout中,然后通过分发调用了TextView中的Behavior中的onNestedPreScroll,在这个方法中,我们是进行了TextView的上下滑动(边界处理我这边就不说了,其实还蛮简单的),看下效果。
我们发现有个问题,就是在TextView上滑离开的之后,RecyclerView上方有一处空白,这个就是因为在TextView滑动的时候,RecyclerView没有跟随TextView一起滑动。
这个不就是我们在2.1中提到的这个效果吗,所以RecyclerView是需要依赖TextView的,我们需要再次自定义一个Behavior,完成这种联动效果。
class RecyclerViewBehavior @JvmOverloads constructor( val context: Context, val attributeSet: AttributeSet ) : CoordinatorLayout.Behavior(context, attributeSet) { override fun layoutDependsOn( parent: CoordinatorLayout, child: RecyclerView, dependency: View ): Boolean { return dependency is TextView } override fun onDependentViewChanged( parent: CoordinatorLayout, child: RecyclerView, dependency: View ): Boolean { Log.e("TAG","onDependentViewChanged ${dependency.bottom} ${child.top}") ViewCompat.offsetTopAndBottom(child,(dependency.bottom - child.top)) return true } }
对应的布局文件,区别在于RecyclerView设置了RecyclerViewBehavior。
这里我设置了RecyclerView依赖于TextView,当TextView的位置发生变化的时候,就会通知RecyclerView的Behavior中的onDependentViewChanged方法,在这个方法中可以设置RecyclerView竖直方向上的偏移量。
具体的偏移量计算,可以根据上图自行推理,因为TextView移动的时候,会跟RecyclerView产生一块位移,RecyclerView需要补上这块,在onDependentViewChanged方法中。
这时候我们会发现,即便最外层没有使用可滑动的布局,依然能够完成吸顶的效果,这就显示了CoordinatorLayout的强大之处,当然除了移动之外,控制View的显示与隐藏、动画效果等等都可以完成,只要熟悉了CoordinatorLayout内部的原理,就不怕UI跟设计老师的任意需求了。
以上就是Android进阶CoordinatorLayout协调者布局实现吸顶效果的详细内容,更多关于Android CoordinatorLayout吸顶的资料请关注脚本之家其它相关文章!