上一篇文章,我们从CoordinatorLayout源码出发,分析了一下Behavior几个重点方法的调用逻辑和流程。知道了整个交互的分发流程。但是具体是怎么让一个不是ScrollingView类型的View(如本篇要讲的AppBarLayout继承制LinearLayout的)怎么产生滑动效果的呢?这就是本篇所要讲解的内容了。
首先,我们先用一张图说明下整个交互逻辑,这里NestedScrollingChild实现类用NestedScrollView(这张图是网上找的)
CoordinatorLayout作为父布局,用于管理和分发交互事件给对应Behavior处理。
系统默认会将AppBarLayout.Behavior和AppBarLayout进行绑定,负责具体处理由CoordinatorLayout分发过来的事件。
NestedScrollView充当滑动子View的角色(实现了NestedScrollingChild接口),在自身滑动过程中将对应事件传递个CoordinatorLayout(实现了NestedScrollingParent2接口),在分发给AppBarLayout.Behavior
ScrollingViewBehavior和NestedScrollView进行绑定(通过),它会依赖于AppBarLayout,从而在AppBarLayout滑动的时候,可以收到回调,做处理。
二、源码分析
1、类简介
上面介绍了整体的交互逻辑,下面就开始进入源码分析吧。
这里NestedScrollView的不是重点分析对象,不会做过多介绍,如果感兴趣的可以看这篇文章。这里我们重点看下AppBarLayout和它的两个内部类:AppBarLayout.Behavior和ScrollingViewBehavior
先说AppBarLayout
还是看看Google的官方介绍吧,我觉得学习过程中真的要多看看Google官方文档,它里面的描述是最准确的。很多时候,我们使用过程中需要注意的事项在里面也会有提及。
AppBarLayout is a vertical LinearLayout which implements many of the features of material designs app bar concept, namely scrolling gestures.
Children should provide their desired scrolling behavior through setScrollFlags(int) and the associated layout xml attribute: app:layout_scrollFlags.
This view depends heavily on being used as a direct child within a CoordinatorLayout. If you use AppBarLayout within a different ViewGroup, most of it's functionality will not work.
AppBarLayout also requires a separate scrolling sibling in order to know when to scroll. The binding is done through the AppBarLayout.ScrollingViewBehavior behavior class, meaning that you should set your scrolling view's behavior to be an instance of AppBarLayout.ScrollingViewBehavior. A string resource containing the full class name is available.
大致翻译下就是:AppBarLayout是垂直方向的LinearLayout,同时遵循了material designs,可以支持很多滑动手势的交互。
通过在xml文件中设置app:layout_scrollFlags属性,或者通过setScrollFlags(int) 可以给子View设置不同的交互逻辑。
同时需要注意,该类只能作为CoordinatorLayout的直接子View,否则它的那些特性将会失效。你看这里Google就清除说明了使用时候的注意事项。所以多看官方文档总是没错的。
为了和滑动控件进行交互绑定,我们需要将AppBarLayout.ScrollingViewBehavior类的全路劲设置给需要进行交互的滑动View(如NestedScrollView)。
介绍完AppBarLayout,我们再看看AppBarLayout.Behavior
The default AppBarLayout.Behavior for AppBarLayout. Implements the necessary nested scroll handling with offsetting.
这个类,介绍很简单,就说是AppBarLayout的默认Behavior,实现了嵌套滑动的交互处理。
最后,我们再看下ScrollingViewBehavior
Behavior which should be used by Views which can scroll vertically and support nested scrolling to automatically scroll any AppBarLayout siblings.
这个类的介绍也很简单,继承自Behavior,需要设置给要自动响应AppBarLayout嵌套滑动事件的View。如NestedScrollView
2、交互流程分析
上面大致说了每个类在交互过程中担任的角色,也大致看了一下简介,下面就进入具体的分析。AppBarLayout和其他可嵌套滑动的View(为了叙述方面,我们都直接用NestedScrollView代替了)之间的交互,无非就两种情况。
- AppBarLayout引起的滑动,然后NestedScrollView需要进行相关处理
- 由NestedScrollView引起的滑动,AppBarLayout需要进行相关处理
所以,下面我们也从这两种方式进行分析,首先我们有AppBarLayout产生滑动的情况,这种情况下,我们的触摸范围是在AppBarLayout所在的范围, 通过上一篇CoordinatorLayout源码分析的学习,我们知道,最终会由CoordinatorLayout分发给它的子View的Behavior处理,这里会给到AppBarLayout.Behavior。所以这里我们就先看下AppBarLayout.Behavior中的onInterceptTouchEvent。但是我们发现,这个类里面没有这个方法。其实我们仔细看,会发现它是继承自HeaderBehavior,HeaderBehavior继承自ViewOffsetBehavior。所以,既然它自己没有这个方法,就找它父类了。在HeaerBehavior中有实现。
@Override
public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
if (mTouchSlop < 0) {
mTouchSlop = ViewConfiguration.get(parent.getContext()).getScaledTouchSlop();
}
final int action = ev.getAction();
// Shortcut since we're being dragged
if (action == MotionEvent.ACTION_MOVE && mIsBeingDragged) {
return true;
}
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
mIsBeingDragged = false;
final int x = (int) ev.getX();
final int y = (int) ev.getY();
// 判断是否可以滑动 并且点击位置要在child(AppBarLayout)的范围内
if (canDragView(child) && parent.isPointInChildBounds(child, x, y)) {
// 记录下点击位置
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
ensureVelocityTracker();
}
break;
}
case MotionEvent.ACTION_MOVE: {
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
// If we don't have a valid id, the touch down wasn't on content.
break;
}
final int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
break;
}
final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
// 判断是不是滑动操作
if (yDiff > mTouchSlop) {
// 设置拦截标志为true
mIsBeingDragged = true;
// 几下当前手指的位置
mLastMotionY = y;
}
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP: {
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
// Fling相关处理(Fling相关知识,不是重点,就自己去补充了)
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
break;
}
}
// Fling相关处理
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(ev);
}
return mIsBeingDragged;
}
可以看到,里面代码还是比较简单的,主要就是判断是否是有效滑动,如果是,我们就拦截事件,然后交给自己的onTouchEvent()处理。具体过程请看方法里面的注释吧。
然后我们就进入onTouchEvent()里面看下吧,我们发现,这个方法也是在HeaderBehavior中处理的。
@Override
public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
if (mTouchSlop < 0) {
mTouchSlop = ViewConfiguration.get(parent.getContext()).getScaledTouchSlop();
}
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
final int x = (int) ev.getX();
final int y = (int) ev.getY();
// 也是判断是否可以滑动 并且在范围内
if (parent.isPointInChildBounds(child, x, y) && canDragView(child)) {
// 是:记下触摸位置
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
ensureVelocityTracker();
} else {
// 不是 直接放行
return false;
}
break;
}
case MotionEvent.ACTION_MOVE: {
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == -1) {
return false;
}
final int y = (int) ev.getY(activePointerIndex);
// 计算滑动距离
int dy = mLastMotionY - y;
// 如果是有效滑动并且之前没有进行过滑动
if (!mIsBeingDragged && Math.abs(dy) > mTouchSlop) {
// 设置滑动标志
mIsBeingDragged = true;
// 微调滑动距离
if (dy > 0) {
dy -= mTouchSlop;
} else {
dy += mTouchSlop;
}
}
// 如果手指是在滑动
if (mIsBeingDragged) {
mLastMotionY = y;
// We're being dragged so scroll the ABL
// 根据手指滑动的距离,移动AppBarLayout
scroll(parent, child, dy, getMaxDragOffset(child), 0);
}
break;
}
case MotionEvent.ACTION_UP:
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(ev);
mVelocityTracker.computeCurrentVelocity(1000);
float yvel = mVelocityTracker.getYVelocity(mActivePointerId);
// 抬起的时候处理的Fling相关逻辑
fling(parent, child, -getScrollRangeForDragFling(child), 0, yvel);
}
// $FALLTHROUGH
case MotionEvent.ACTION_CANCEL: {
// 取消 重置滑动标志
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
break;
}
}
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(ev);
}
return true;
}
方法注释里里面已经说的比较清楚了,可以看到。有效滑动的时候,我们交给scroll()方法进行处理的,然后Fling操作是交给fling()方法处理的。我们先看scroll()方法
final int scroll(CoordinatorLayout coordinatorLayout, V header,
int dy, int minOffset, int maxOffset) {
return setHeaderTopBottomOffset(coordinatorLayout, header,
getTopBottomOffsetForScrollingSibling() - dy, minOffset, maxOffset);
}
又给到了setHeaderTopBottomOffset()处理,那我们继续跟踪,这里要注意下。AppBarLayout.Behavior重写了该方法,所以这里需要进入AppBarLayout.Behavior里面看实现逻辑:
@Override
//newOffeset:AppBarLayout已经移动的距离-手指滑动的距离(手指向上滑为正,向下滑为负)
//minOffset:负的AppBarLayout的height,
//maxOffset:0。
int setHeaderTopBottomOffset(CoordinatorLayout coordinatorLayout,
AppBarLayout appBarLayout, int newOffset, int minOffset, int maxOffset) {
// 获取当前AppBarLayout的偏移量(已经移动过的距离)
final int curOffset = getTopBottomOffsetForScrollingSibling();
int consumed = 0;
// 合法性检测:AppBarLayout滑动的距离必须在minOffset和maxOffset之间
if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
// 计算出最终可以移动的距离
newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
// 判断是否需要移动(newOffset就是本次滑动最终需要产生的偏移量)
// 如果当前的偏移量和最终需要的不相等,才进行移动
if (curOffset != newOffset) {
final int interpolatedOffset = appBarLayout.hasChildWithInterpolator()
// 如果我们自己设置了插值器 会进行条用
? interpolateOffset(appBarLayout, newOffset)
: newOffset;
// 最终通过ViewCompat.offsetTopAndBottom()移动AppBarLayout
final boolean offsetChanged = setTopAndBottomOffset(interpolatedOffset);
// 更新消费的距离
consumed = curOffset - newOffset;
// 如果没有设置Interpolator 为0
mOffsetDelta = newOffset - interpolatedOffset;
if (!offsetChanged && appBarLayout.hasChildWithInterpolator()) {
// 虽然这里没有移动操作 但是在我们自己设置的插值器中可能产生了移动 需要给依赖的View发送通知
coordinatorLayout.dispatchDependentViewsChanged(appBarLayout);
}
// 回调OnOffsetChangedListener监听
appBarLayout.dispatchOffsetUpdates(getTopAndBottomOffset());
// 根据我们设置的ScrollFlags调整状态
updateAppBarLayoutDrawableState(coordinatorLayout, appBarLayout, newOffset,
newOffset < curOffset ? -1 : 1, false);
}
} else {
// Reset the offset delta
mOffsetDelta = 0;
}
return consumed;
}
在这里面,我们看到,正常情况下就会setTopAndBottomOffset()方法来移动我们的AppBarLayout了,同时做回调通知,最后根据设置的不同的scrolls_flag更新子View的状态(该隐藏的隐藏,该出来的出来)。
到这里,我们知道AppBarLayout是怎么通过手指滑动产生移动的了,但是我们实际使用中,AppBarLayout发生移动的同时,NestedScrollView也会跟着上下滚动,NestedScrollView又是怎么知道需要滚动的呢?还记得在介绍类的时候说了,必须给NestedScrollView设置ScrollingViewBehavior么。玄机就在这个类里面,我们看下该类:
public static class ScrollingViewBehavior extends HeaderScrollingViewBehavior {
public ScrollingViewBehavior() {}
public ScrollingViewBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
final TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.ScrollingViewBehavior_Layout);
setOverlayTop(a.getDimensionPixelSize(
R.styleable.ScrollingViewBehavior_Layout_behavior_overlapTop, 0));
a.recycle();
}
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
// 同AppBarLayout产生关联,那AppBarLayout发生移动的时候,就会收到回调了
return dependency instanceof AppBarLayout;
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child,
View dependency) {
// 这里收到AppBarLayout移动的通知,然后就跟着移动了
offsetChildAsNeeded(parent, child, dependency);
return false;
}
……
private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {
// 获取到AppBarLayout的Behavior
final CoordinatorLayout.Behavior behavior =
((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
if (behavior instanceof Behavior) {
// Offset the child, pinning it to the bottom the header-dependency, maintaining
// any vertical gap and overlap
final Behavior ablBehavior = (Behavior) behavior;
// 计算出需要移动的距离,并跟随移动
ViewCompat.offsetTopAndBottom(child, (dependency.getBottom() - child.getTop())
+ ablBehavior.mOffsetDelta
+ getVerticalLayoutGap()
- getOverlapPixelsForOffset(dependency));
}
}
……
}
这里就不多解释了,看过上篇文章应该很容易就理解了,NestedScrollView和AppBarLayout通过这个类产生了依赖关系。AppBarLayout移动的时候会通知进入这里。就可以做同步移动操作了。
到这里,AppBarLayout的移动,并通知NestedScrollView同时移动的过程就分析完了。其实在onTouchEvent()中ACTION_UP的时候,还有一个fling()方法进行惯性滑动的操作。这里就不具体分析,机制也是一样,感兴趣的同学自己看下源码就明白了。只是这里说明一点,因为Fling的主体是AppBarLayout,所以不管我们在其上面滑动多块,最多也就只能滑动AppBarLayout的最大可滑动距离,而不会去滑动NestedScrollView。
说完AppBarLayout的滑动后,我们接着看我们之前说的第二种情况,由NestedScrollView滑动,引起AppBarLayout相应移动的逻辑了。我们知道,NestedScrollView滑动的时候,会通过CoordinatorLayout将相关的方法代理给Behavior来处理(这里也就是AppBarLayout.Behavior)。根据嵌套滑动机制,会首先调用onStartNestedScroll()方法。那我们进看下这个实现逻辑吧:
@Override
//directTargetChild=target=NestedScrollView
public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child,
View directTargetChild, View target, int nestedScrollAxes, int type) {
//如果滑动方向为VERTICAL,有足够空间进行滑动,并且有可滑动Child,started=true;
final boolean started = (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
&& child.hasScrollableChildren()
&& parent.getHeight() - directTargetChild.getHeight() <= child.getHeight();
if (started && mOffsetAnimator != null) {
// Cancel any offset animation
mOffsetAnimator.cancel();
}
// A new nested scroll has started so clear out the previous ref
mLastNestedScrollingChildRef = null;
return started;
}
这里主要方向和有足够滑动空间比较好理解,说下要有可以滑动的Child这个判断,看下具体实现:
boolean hasScrollableChildren() {
return getTotalScrollRange() != 0;
}
public final int getTotalScrollRange() {
if (mTotalScrollRange != INVALID_SCROLL_RANGE) {
return mTotalScrollRange;
}
int range = 0;
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final int childHeight = child.getMeasuredHeight();
final int flags = lp.mScrollFlags;
if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) {
// We're set to scroll so add the child's height
range += childHeight + lp.topMargin + lp.bottomMargin;
if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// For a collapsing scroll, we to take the collapsed height into account.
// We also break straight away since later views can't scroll beneath
// us
range -= ViewCompat.getMinimumHeight(child);
break;
}
} else {
// As soon as a view doesn't have the scroll flag, we end the range calculation.
// This is because views below can not scroll under a fixed view.
break;
}
}
return mTotalScrollRange = Math.max(0, range - getTopInset());
}
就是通过判断ChildView的scroll_flags属性,计算设置了SCROLL_FLAG_SCROLL属性的Child高度和,设置了该属性就代表是可以滚动的View,然后减去设置SCROLL_FLAG_EXIT_UNTIL_COLLAPSED的折叠高度,为什么要减去呢,因为这个标志,代表该View会变小知道达到最小高度,所以该控件的最小高度是不能计算在可滚动范围内的。
回到主流程上,所以正常情况下,onStartNestedScroll()是会返回true,也就是告诉系统我要消费滑动,后面就会继续进入onNestedPreScroll()方法。
@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
View target, int dx, int dy, int[] consumed, int type) {
if (dy != 0) {
int min, max;
if (dy < 0) {
// 向下滚动
min = -child.getTotalScrollRange(); // 该方法上面已经讲过了,获取到可滚动距离,然后取负数
max = min + child.getDownNestedPreScrollRange(); // 判断有没有向下滑动需要立即滑出的距离
} else {
// 向上滚动
min = -child.getUpNestedPreScrollRange(); // 内部就是getTotalScrollRange()
max = 0;
}
if (min != max) {
// 通过scroll方法进行滑动处理
consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
}
}
}
上面注释已经说的比较清楚了。就是根据滑动方向,然后分别做好边界值的处理。最后通过scroll()方法进行滑动处理。
getUpNestedPreScrollRange() 就是调用的getTotalScrollRange(),上面已经讲了,不再赘述。getDownNestedPreScrollRange()这个方法会获取到下滑的时候,需要立即移动的距离,然后加上min的值给到max。这样在后面通过scroll()方法处理移动的时候,才能够展示,如果这里返回时0,代表没有需要里展示的情况,这样min和max两个值相等,最终sroll()内部就不会产生移动,consumed[1]=0,最终就会给NestedScrollView自己去消费滑动距离。
int getDownNestedPreScrollRange() {
if (mDownPreScrollRange != INVALID_SCROLL_RANGE) {
// If we already have a valid value, return it
return mDownPreScrollRange;
}
int range = 0;
for (int i = getChildCount() - 1; i >= 0; i--) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final int childHeight = child.getMeasuredHeight();
final int flags = lp.mScrollFlags;
if ((flags & LayoutParams.FLAG_QUICK_RETURN) == LayoutParams.FLAG_QUICK_RETURN) {
// First take the margin into account
range += lp.topMargin + lp.bottomMargin;
// The view has the quick return flag combination...
if ((flags & LayoutParams.SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED) != 0) {
// If they're set to enter collapsed, use the minimum height
range += ViewCompat.getMinimumHeight(child);
} else if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) {
// Only enter by the amount of the collapsed height
range += childHeight - ViewCompat.getMinimumHeight(child);
} else {
// Else use the full height (minus the top inset)
range += childHeight - getTopInset();
}
} else if (range > 0) {
// If we've hit an non-quick return scrollable view, and we've already hit a
// quick return view, return now
break;
}
}
return mDownPreScrollRange = Math.max(0, range);
}
就是判断是否有设置enterAlways或者enterAlwaysCollapsed的flag。如果只有scroll|enterAlways,下滑的时候,需要先将控件显示出来,需要加上整个View的高度。如果是scroll|enterAlways|enterAlwaysCollapsed,需要先滑出控件的最小高度,需要加上控件的最小高度。
接着继续嵌套滑动机制的下一步骤onNestedScroll()和onStopNestedScroll()方法。这两个方法比较简单,就一起介绍了
@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
int type) {
if (dyUnconsumed < 0) {
// If the scrolling view is scrolling down but not consuming, it's probably be at
// the top of it's content
scroll(coordinatorLayout, child, dyUnconsumed,
-child.getDownNestedScrollRange(), 0);
}
}
@Override
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout abl,
View target, int type) {
if (type == ViewCompat.TYPE_TOUCH) {
// If we haven't been flung then let's see if the current view has been set to snap
snapToChildIfNeeded(coordinatorLayout, abl);
}
// Keep a reference to the previous nested scrolling child
mLastNestedScrollingChildRef = new WeakReference<>(target);
}
onNestedScroll()就是判断是否还有未消费的滑动距离,如果有,就直接交给scroll()方法进行处理。一般就是NestedScrollView已经滑到边界的时候,才会有剩余未消费的距离,其他情况一般dyUnconsumed=0
onStopNestedScroll()就是对吸附效果进行处理,也就是snap标志位,判断最终是要全部显示设置了snap的View还是全部隐藏。