功能
CoordinatorLayout 是一个“增强版”的 FrameLayout,它的主要作用就是作为一系列相互之间有交互行为的子View的容器。CoordinatorLayout像是一个事件转发中心,它感知所有子View的变化,并把这些变化通知给其他子View。
Behavior就像是CoordinatorLayout与子View之间的通信协议,通过给CoordinatorLayout的子View指定Behavior,就可以实现它们之间的交互行为。Behavior可以用来实现一系列的交互行为和布局变化,比如说侧滑菜单、可滑动删除的UI元素,以及跟随着其他UI控件移动的按钮等。文字表达不够直观,直接看下面的效果图:
image依赖
dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
}
简单使用
很多文章讲CoordinatorLayout 时候常将AppBarLayout,CollapsingToolbarLayout放到一起去做Demo,虽然看上去做出来比较酷炫的效果,但是对于初学者而言不太好get到CoordinatorLayout以及Behavior在其中到底起到什么作用。这里用如下一个简单的Demo演示下,一个紫色按钮跟随黑块(MoveView)反向移动。
简单Demo.gifMoveView的代码非常简单,就是随着Touch事件的变化,改变自身的translation ,不是重点。
定义Behavior
由于我们这里只关心MoveView的位置变化,只用实现如下两个方法:
- layoutDependsOn 返回true表示child依赖dependency , dependency的measure和layout都会在child之前进行,并且当dependency的大小位置发生变化时候会回调 onDependentViewChanged
- onDependentViewChanged 当一个依赖的View的大小或位置发生变化时候会调用
class FollowBehavior : CoordinatorLayout.Behavior<View> {
constructor() : super()
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: View,
dependency: View
): Boolean {
return dependency is MoveView
}
private var dependencyX = Float.MAX_VALUE
private var dependencyY = Float.MAX_VALUE
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: View,
dependency: View
): Boolean {
if (dependencyX == Float.MAX_VALUE || dependencyY == Float.MAX_VALUE) {
dependencyX = dependency.x
dependencyY = dependency.y
} else {
val dX = dependency.x - dependencyX
val dy = dependency.y - dependencyY
child.translationX -= dX
child.translationY -= dy
dependencyX = dependency.x
dependencyY = dependency.y
}
return true
}
}
绑定Behavior
绑定Behavior有两种方式:
- 通过布局参数去设置,你可以在xml中指定,当然也可以在Java代码中通过CoordinatorLayout.LayoutParams动态指定
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.threeloe.testdemo.view.MoveView
android:background="@color/black"
android:layout_width="100dp"
android:layout_gravity="center_vertical"
android:layout_height="100dp"/>
<Button
android:id="@+id/btn"
android:layout_gravity="center_vertical"
android:layout_marginStart="200dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="跟随黑块移动"
app:layout_behavior="com.threeloe.testdemo.behavior.FollowBehavior"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
- 默认绑定Behavior ,让View实现AttachedBehavior接口,实现getBehavior方法即可。这个优先级比布局参数低,当布局参数中没有指定Behavior时候会使用AttachedBehavior返回的。 之前的版本是使用DefaultBehavior注解,由于性能原因已经弃用。
class FollowTextView : TextView, CoordinatorLayout.AttachedBehavior{
override fun getBehavior(): CoordinatorLayout.Behavior<*> {
return FollowBehavior()
}
}
优点
- Behavior的复用性非常好,比如FollowBehavior可以给任何其他的子View直接使用。
- 当场景复杂的情况下Behavior也能表现出良好的解耦,在没有CoordinatorLayout的情况下,我们会给MoveView设计一个监听变化的接口,然后紫色按钮去监听Move的变化,然后自身移动。这在简单的场景下,不显得有什么,一旦场景变得复杂,相互之间有交互的子View较多的情况下,就会注册各种监听,代码之间的耦合会变得比较严重。CoordinatorLayout将各种子View的布局以及交互等行为抽象为Behavior,并对Behavior进行管理,实现了代码的解耦。
进阶使用(Behavior拦截一切)
Behavior几乎可以拦截所有View的行为,给子View添加Behavior之后,可以拦截到父View CoordinatorLayout的measure,layout, 触摸事件,嵌套滑动等等。 我们通过下面这个常见的Demo来说明:
进阶使用.gif对应的xml如下所示,实现非常简单整体上就是一个AppBarLayout + NestedScrollVIew.
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="二月二,龙抬头..." />
</androidx.core.widget.NestedScrollView>
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:layout_scrollFlags="scroll|enterAlways"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:title="Title" />
<TextView
android:background="@color/purple_200"
android:textColor="@color/white"
android:text="惊蛰"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="45dp"/>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
这个Demo非常常见,但是我相信并不是所有的同学都能回答出来下面几个问题:
- 我们开篇就说过,CoordinatorLayout是一个“增强版”的FrameLayout,那为什么上述xml中NestedScrollView没有设置任何的marginTop内容却没有被遮挡?
- NestedScrollView实际测量的高度应该是多大?
- 为什么手指按在AppBarLayout的区域上也能触发滑动事件?
- 为什么手指在NestedScrollView上滑动能把ToolBar “顶出去” ?
我会通过以上四个问题帮大家更好理解Behavior的作用
拦截Measure/Layout
第一个问题中按我们理解ToolBar应该挡住NestedScrollView最上面一部分才对,但展示出来却刚好在ToolBar的下方,这其实是因为Behavior其实提供了onMeasureChild,onLayoutChild让我们自己去接管对子VIew的测量和布局。上述中NestedScrollView使用了ScrollingViewBehavior,它是设计给能在竖直方向上滑动并且支持嵌套滑动的View使用的,使用这个Behavior能够和AppBarLayout之间产生联动效果。
首先看ScrollingViewBehavior的layoutDependsOn方法,是依赖于AppBarLayout的。
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
// We depend on any AppBarLayouts
return dependency instanceof AppBarLayout;
}
我们知道View的位置是由layout过程决定的,所以我们直接看ScrollingViewBehavior的
boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection)
方法,最终找到关键的逻辑在父类HeaderScrollingViewBehavior的layoutChild中,关键代码主要就三行:
@Override
protected void layoutChild(
@NonNull final CoordinatorLayout parent,
@NonNull final View child,
final int layoutDirection) {
final List<View> dependencies = parent.getDependencies(child);
//header即是AppBarLayout
final View header = findFirstDependency(dependencies);
if (header != null) {
final CoordinatorLayout.LayoutParams lp =
(CoordinatorLayout.LayoutParams) child.getLayoutParams();
final Rect available = tempRect1;
available.set(
parent.getPaddingLeft() + lp.leftMargin,
//top的位置是在header的bottom下
header.getBottom() + lp.topMargin,
parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
parent.getHeight() + header.getBottom() - parent.getPaddingBottom() - lp.bottomMargin);
...
final Rect out = tempRect2;
//RTL处理
GravityCompat.apply(
resolveGravity(lp.gravity),
child.getMeasuredWidth(),
child.getMeasuredHeight(),
available,
out,
layoutDirection);
final int overlap = getOverlapPixelsForOffset(header);
child.layout(out.left, out.top - overlap, out.right, out.bottom - overlap);
verticalLayoutGap = out.top - header.getBottom();
} else {
// If we don't have a dependency, let super handle it
super.layoutChild(parent, child, layoutDirection);
verticalLayoutGap = 0;
}
}
我们给NestedScrollView设置高度为match_parent,那它的实际高度真的就是和CoordinatorLayout一样高么?实际并不是,因为它在屏幕上能展示的最大高度只有如下黄色箭头部分的长度,如果高度太大的话可能会导致一部分内容展示不出来。
image这部分逻辑我们可以在onMeasureChild方法中找到:
public boolean onMeasureChild(
@NonNull CoordinatorLayout parent,
@NonNull View child,
int parentWidthMeasureSpec,
int widthUsed,
int parentHeightMeasureSpec,
int heightUsed) {
final int childLpHeight = child.getLayoutParams().height;
//如果是match_parent或者wrap_content
if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
|| childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
final List<View> dependencies = parent.getDependencies(child);
//获取到AppBarLayout
final View header = findFirstDependency(dependencies);
if (header != null) {
//父View也就是CoordinatorLayout的高度
int availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec);
...
//getScrollRange(header)是AppBarLayout中可以滑动的范围,对于上述Demo中就是ToolBar的高度
int height = availableHeight + getScrollRange(header);
//AppBarLayout的整个高度
int headerHeight = header.getMeasuredHeight();
if (shouldHeaderOverlapScrollingChild()) {
child.setTranslationY(-headerHeight);
} else {
//得到屏幕上黄色箭头的高度
height -= headerHeight;
}
final int heightMeasureSpec =
View.MeasureSpec.makeMeasureSpec(
height,
childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
? View.MeasureSpec.EXACTLY
: View.MeasureSpec.AT_MOST);
// Now measure the scrolling view with the correct height
parent.onMeasureChild(
child, parentWidthMeasureSpec, widthUsed, heightMeasureSpec, heightUsed);
return true;
}
}
return false;
}
拦截Touch事件
我们知道正常情况下,View要响应Touch事件肯定要覆写View的onTouchEvent方法的,但是AppBarLayout并没有覆写。我们当然可以继续联想Behavior, 但是上述xml中并没有看到AppBarLayout有通过布局参数指定Behavior,不要忘了还有默认绑定的方法。
@Override
@NonNull
public CoordinatorLayout.Behavior<AppBarLayout> getBehavior() {
return new AppBarLayout.Behavior();
}
Behavior同样提供了onInterceptTouchEvent和onTouchEvent让子View自己去处理Touch事件。
onInterceptTouchEvent如下:
public boolean onInterceptTouchEvent(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev) {
...
// 如果是move事件并且在拖动中,就计算yDiff并拦截事件
if (ev.getActionMasked() == MotionEvent.ACTION_MOVE && isBeingDragged) {
if (activePointerId == INVALID_POINTER) {
// If we don't have a valid id, the touch down wasn't on content.
return false;
}
int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
return false;
}
int y = (int) ev.getY(pointerIndex);
int yDiff = Math.abs(y - lastMotionY);
if (yDiff > touchSlop) {
lastMotionY = y;
return true;
}
}
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
activePointerId = INVALID_POINTER;
int x = (int) ev.getX();
int y = (int) ev.getY();
//如果canDragView并且事件是在子View的范围中就认为进入拖动状态
isBeingDragged = canDragView(child) && parent.isPointInChildBounds(child, x, y);
if (isBeingDragged) {
lastMotionY = y;
activePointerId = ev.getPointerId(0);
ensureVelocityTracker();
// There is an animation in progress. Stop it and catch the view.
if (scroller != null && !scroller.isFinished()) {
scroller.abortAnimation();
return true;
}
}
}
if (velocityTracker != null) {
velocityTracker.addMovement(ev);
}
return false;
}
canDragView的逻辑如下,只有当NestedScrollView的scrollY是0的时候,也就是还没滑动过时候,才能拖动AppBarLayout。
@Override
boolean canDragView(T view) {
...
// Else we'll use the default behaviour of seeing if it can scroll down
if (lastNestedScrollingChildRef != null) {
// If we have a reference to a scrolling view, check it
final View scrollingView = lastNestedScrollingChildRef.get();
return scrollingView != null
&& scrollingView.isShown()
&& !scrollingView.canScrollVertically(-1);
} else {
// Otherwise we assume that the scrolling view hasn't been scrolled and can drag.
return true;
}
}
onTouchEvent方法中计算移动距离dy,然后调用scroll方法滚动。
@Override
public boolean onTouchEvent(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev) {
boolean consumeUp = false;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
final int activePointerIndex = ev.findPointerIndex(activePointerId);
if (activePointerIndex == -1) {
return false;
}
final int y = (int) ev.getY(activePointerIndex);
int dy = lastMotionY - y;
lastMotionY = y;
// We're being dragged so scroll the ABL
scroll(parent, child, dy, getMaxDragOffset(child), 0);
break;
...
return isBeingDragged || consumeUp;
}
还有一个问题是在AppBarLayout scroll的过程中,NestedScrollView是怎么移动的呢?这个问题其实就是和我们“简单使用”部分的那个问题类似,毫无疑问是在ScrollingViewBehavior的onDependentViewChanged中实现的,这里不再具体分析代码了。
拦截嵌套滑动
最后一个问题,为什么手指在NestedScrollView上滑动能把ToolBar “顶出去” ?这个如果从传统的事件分发角度看的话好像已经超出了我们的“认知”,一个滑动事件怎么能从一个View转移给另一个平级的子View,在了解这个之前我们需要先了解下NestedScroling机制,本文只做简单介绍,需要详细了解的话可以看这篇NestedScrolling机制详解 。
NestedScrolling机制
NestedScroling机制提供两个接口:
- NestedScrollingParent,嵌套滑动的父View需要实现。已有实现CoordinatorLayout,NestedScroView
- NestedScrollingChild, 嵌套滑动的子View需要实现。已有实现RecyclerView,NestedScroView
由于发现设计的能力有些不足,Google前后又引入NestedScrollingParent2/NestedScrollingChild2以及NestedScrollingParent3/NestedScrollingChild3。
Google在给我提供这两个接口的时候,同时也给我们提供了实现这两个接口时一些方法的标准实现,
分别是
- NestedScrollingChildHelper
- NestedScrollingParentHelper
我们在实现上面两个接口的方法时,只需要调用相应Helper中相同签名的方法即可。
基本原理:
对原始的事件分发机制做了一层封装,子View实现NestedScrollingChild接口,父View实现NestedScrollingParent 接口。 在NetstedScroll的世界里,NestedScrollingChild是发动机,它自己和父VIew都能消费滑动事件,但是父VIew具有优先消费权。假设产生一个竖直滑动,简单来说滑动事件会由NestedScrollingChild先接收到产生一个dy,然后询问NestedScrollingParent要消耗多少(dyConsumed),自己再拿dy-dyConsumed来进行滑动。当然NestedScrollingChild有可能自己本身也并不会消耗完,此时会再向父View报告情况。
嵌套滑动.png在我们的Demo中CoordinatorLayout就是这个滑动事件的转发中心,它接收到来自NestedScrollView的滑动事件,并将这些事件通过Behavior转发给AppBarLayout。
AppBarLayout.Behavior相关实现
- onStartNestedScroll 决定是否要接受嵌套滑动事件
@Override
public boolean onStartNestedScroll(
@NonNull CoordinatorLayout parent,
@NonNull T child,
@NonNull View directTargetChild,
View target,
int nestedScrollAxes,
int type) {
// 如果是竖直方向的滚动并且有可滚动的child
final boolean started =
(nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
&& (child.isLiftOnScroll() || canScrollChildren(parent, child, directTargetChild));
if (started && offsetAnimator != null) {
// Cancel any offset animation
offsetAnimator.cancel();
}
// A new nested scroll has started so clear out the previous ref
lastNestedScrollingChildRef = null;
// Track the last started type so we know if a fling is about to happen once scrolling ends
lastStartedType = type;
return started;
}
private boolean canScrollChildren(
@NonNull CoordinatorLayout parent, @NonNull T child, @NonNull View directTargetChild) {
//总滑动范围大约0 并且 CoordinatorLayout 减去NestedScrollView的高度小于 AppBarLayout的高度
return child.hasScrollableChildren()
&& parent.getHeight() - directTargetChild.getHeight() <= child.getHeight();
}
- onNestedPreScroll 在NestedScrollChild滑动之前决定自己是否要消耗
@Override
public void onNestedPreScroll(
CoordinatorLayout coordinatorLayout,
@NonNull T child,
View target,
int dx,
int dy,
int[] consumed,
int type) {
if (dy != 0) {
int min;
int max;
if (dy < 0) {
// 向下滑动
min = -child.getTotalScrollRange();
max = min + child.getDownNestedPreScrollRange();
} else {
// 向上滑 ,确定滚动范围
min = -child.getUpNestedPreScrollRange();
max = 0;
}
if (min != max) {
// 竖直方向的消耗复制,传回给NestedScrollView
consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
}
}
if (child.isLiftOnScroll()) {
child.setLiftedState(child.shouldLift(target));
}
}
final int scroll(
CoordinatorLayout coordinatorLayout, V header, int dy, int minOffset, int maxOffset) {
return setHeaderTopBottomOffset(
coordinatorLayout,
header,
//计算新的offset
getTopBottomOffsetForScrollingSibling() - dy,
minOffset,
maxOffset);
}
int setHeaderTopBottomOffset(
CoordinatorLayout parent, V header, int newOffset, int minOffset, int maxOffset) {
final int curOffset = getTopAndBottomOffset();
int consumed = 0;
if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
//边界处理
newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
if (curOffset != newOffset) {
//将整个View的位置再竖直方向上平移
setTopAndBottomOffset(newOffset);
// Update how much dy we have consumed
consumed = curOffset - newOffset;
}
}
return consumed;
}
- 子View滑动完毕之后决定自己是否要消耗滑动事件
@Override
public void onNestedScroll(
CoordinatorLayout coordinatorLayout,
@NonNull T child,
View target,
int dxConsumed,
int dyConsumed,
int dxUnconsumed,
int dyUnconsumed,
int type,
int[] consumed) {
if (dyUnconsumed < 0) {
//NestedScroll View向下滑,滑动到自己内容的顶部时候,dy并没有消耗完毕,这个时候事件给AppBarLayout继续滑动
consumed[1] =
scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0);
}
if (dyUnconsumed == 0) {
// The scrolling view may scroll to the top of its content without updating the actions, so
// update here.
updateAccessibilityActions(coordinatorLayout, child);
}
}
- 停止嵌套滑动
@Override
public void onStopNestedScroll(
CoordinatorLayout coordinatorLayout, @NonNull T abl, View target, int type) {
// onStartNestedScroll for a fling will happen before onStopNestedScroll for the scroll. This
// isn't necessarily guaranteed yet, but it should be in the future. We use this to our
// advantage to check if a fling (ViewCompat.TYPE_NON_TOUCH) will start after the touch scroll
// (ViewCompat.TYPE_TOUCH) ends
if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
// If we haven't been flung, or a fling is ending
snapToChildIfNeeded(coordinatorLayout, abl);
if (abl.isLiftOnScroll()) {
abl.setLiftedState(abl.shouldLift(target));
}
}
// Keep a reference to the previous nested scrolling child
lastNestedScrollingChildRef = new WeakReference<>(target);
}
网友评论