今天学习整理一下AppBarLayout与CoordinatorLayout以及Behavior交互逻辑的过程,首先使用一张图先概括一下各个类主要功能吧(本文章使用NestedScrollView充当滑动的内嵌子View)。

android左右联动列表 android appbarlayout 联动滑动_移动开发

  • CoordinatorLayout实现NestedScrollingParent2接口,用于处理与滑动子View的联动交互(这里使用的是NestedScrollView),实际上交由Behavior进行处理,CoordinatorLayout为其代理类。
  • AppBarLayout中默认使用了AppBarLayout.Behavior,主要功能是接收CoordinatorLayout传输过来的滑动事件,并且相对应的进行处理,如NestedScrollView往上滑动到头时候,继续滑动则移动AppBarLayout到头。
  • NestedScrollView实现了NestedScrollingChild2接口,用于传输给CoordinatorLayout,并且消费CoordinatorLayout不消费的触摸事件,其中还是使用了AppBarLayout.ScrollingViewBehavior,功能是进行监听AppBarLayout的位移变化,从而进行相对应的变化,最明显的例子就是AppBarLayout上移过程中,NestedScrollView一起上移。

底下代码分析建立在下面例子之中:

<android.support.design.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"
    android:id="@+id/coordinator"
    tools:context=".photo.TestActivity">
    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:id="@+id/appbar"
        android:layout_height="220dp"
        android:background="#ffffff">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_scrollFlags="scroll"
            android:orientation="vertical">

        </LinearLayout>
    </android.support.design.widget.AppBarLayout>
    <View
        android:layout_width="match_parent"
        android:id="@+id/edit"
        android:background="#e29de3"
        android:layout_height="50dp">

    </View>
    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="500dp"
        android:background="#1d9d29"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
		....
    </android.support.v4.widget.NestedScrollView>

</android.support.design.widget.CoordinatorLayout>

那么现在我们直接看是看源码吧,这里主要弄明白两个逻辑:

  1. 当手指触摸AppBarLayout时候的滑动逻辑。
  2. 当手指触摸NestedScrollView时的滑动逻辑。

当手指触摸AppBarLayout时候的滑动逻辑

在弄清手指触摸AppBarLayout时候的滑动逻辑,需要了解一下AppBarLayout.Behavior这个类,ApprBarLayout的默认Behavior就是AppBarLayout.Behavior这个类,而AppBarLayout.Behavior继承自HeaderBehaviorHeaderBehavior又继承自ViewOffsetBehavior,这里先总结一下两个类的作用,需要详细的实现的请自行阅读源码吧:

  • ViewOffsetBehavior:该Behavior主要运用于View的移动,从名字就可以看出来,该类中提供了上下移动,左右移动的方法。
  • HeaderBehavior:该类主要用于View处理触摸事件以及触摸后的fling事件。

由于上面两个类功能的实现,使得AppBarLayout.Behavior具有了同时移动本身以及处理触摸事件的功能,在CoordinatorLayout四部曲学习之二:CoordinateLayout源码学习这篇文章又说明了CoordinateLayout的NestedScrollingParent2的实现全权委托给了Behavior类,所以AppBarLayout.Behavior就提供了ApprBarLayout对应的联动的方案。

那么我们直接从一开始入手,当我们手碰到AppBarLayout的时候,最终方法经由CoordinateLayout.OnInterceptEvent(...)调用了AppBarLayout.Behavior的对应方法中,上面说了HeaderBehavior处理了触摸事件,那么我们就看下对应的方法:

@Override
    public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
      ....
        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();
                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) {
                    mIsBeingDragged = true;
                    mLastMotionY = y;
                }
                break;
            }

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP: {
                mIsBeingDragged = false;
                mActivePointerId = INVALID_POINTER;
                if (mVelocityTracker != null) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;
            }
        }

        if (mVelocityTracker != null) {
            mVelocityTracker.addMovement(ev);
        }

        return mIsBeingDragged;
    }

上述代码非常简单,就是返回mIsBeingDragged,当移动过程中大于TouchSlop的时候,拦截时间,进而交给onTouchEvent(...)做处理:

public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
       ...
        switch (ev.getActionMasked()) {
            ...
            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
                    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(parent, child, -getScrollRangeForDragFling(child), 0, yvel);
                }
         ...
        return true;
    }

主要逻辑还是在ACTION_MOVE中,可以看到在滑动过程中调用了scroll(...)方法,scroll(...)方法在HeaderBehavior中进行实现,最终调用到了额setHeaderTopBottomOffset(...)方法,该方法在AppBarLayout.Behavior中进行了重写,所以,我们直接看AppBarLayout.Behavior中的源码即可:

@Override
   //newOffeset传入了dy,也就是我们手指移动距离上一次移动的距离,
   //minOffset等于AppBarLayout的负的height,maxOffset等于0。
        int setHeaderTopBottomOffset(CoordinatorLayout coordinatorLayout,
                AppBarLayout appBarLayout, int newOffset, int minOffset, int maxOffset) {
            final int curOffset = getTopBottomOffsetForScrollingSibling();//获取当前的滑动Offset
            int consumed = 0;
			//AppBarLayout滑动的距离如果超出了minOffset或者maxOffset,则直接返回0
            if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
               //矫正newOffset,使其minOffset<=newOffset<=maxOffset
                newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
				//由于默认没设置Interpolator,所以interpolatedOffset=newOffset;
                if (curOffset != newOffset) {
                    final int interpolatedOffset = appBarLayout.hasChildWithInterpolator()
                            ? interpolateOffset(appBarLayout, newOffset)
                            : newOffset;
					//调用ViewOffsetBehvaior的方法setTopAndBottomOffset(...),最终通过
					//ViewCompat.offsetTopAndBottom()移动AppBarLayout
                    final boolean offsetChanged = setTopAndBottomOffset(interpolatedOffset);

                    //记录下消费了多少的dy。
                    consumed = curOffset - newOffset;
                   //没设置Interpolator的情况, mOffsetDelta永远=0
                    mOffsetDelta = newOffset - interpolatedOffset;
					....
                     //分发回调OnOffsetChangedListener.onOffsetChanged(...)
                    appBarLayout.dispatchOffsetUpdates(getTopAndBottomOffset());

                  
                    updateAppBarLayoutDrawableState(coordinatorLayout, appBarLayout, newOffset,
                            newOffset < curOffset ? -1 : 1, false);
                }
           ...
            return consumed;
        }

上面注释也解释的比较清楚了,通过setTopAndBottomOffset()来达到了移动我们的AppBarLayout,那么这里AppBarLayout就可以跟着手上下移动了,但是,NestedScrollView还没跟着移动呢,如果按照上面的分析来看。上面的总结可以得知,NestedScrollView也实现了一个ScrollingViewBehaviorScrollingViewBehavior也继承自ViewOffsetBehavior,说明当前的NestedScrollView也具备上下移动的功能,在阅读ScrollingViewBehavior源码中发现其实现了如下方法:

@Override
        public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
            // We depend on any AppBarLayouts
            return dependency instanceof AppBarLayout;
        }

        @Override
        public boolean onDependentViewChanged(CoordinatorLayout parent, View child,
                View dependency) {
            offsetChildAsNeeded(parent, child, dependency);
            return false;
        }

通过上面方法,并且结合CoordinatorLayout四部曲学习之二:CoordinateLayout源码学习该文章的分析可以知道,NestedScrollView依赖于AppBarLayout,在AppBarLayout移动的过程中,NestedScrollView会随着AppBarLayout的移动回调onDependentViewChanged(...)方法,进而调用offsetChildAsNeeded(parent, child, dependency)

private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {
            final CoordinatorLayout.Behavior behavior =
                    ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
            if (behavior instanceof Behavior) {
                final Behavior ablBehavior = (Behavior) behavior;//获取AppBarLayout的behavior
				//移动对应的距离
                ViewCompat.offsetTopAndBottom(child, (dependency.getBottom() - child.getTop())
                        + ablBehavior.mOffsetDelta
                        + getVerticalLayoutGap()
                        - getOverlapPixelsForOffset(dependency));
            }
        }

这样我们就知道了当手指移动AppBarLayout时候的过程,下面整理一下:

首先通过Behavior.onTouchEvent(...)收到滑动距离,进而通知AppBarLayout.Behavior调用ViewCompat.offsetTopAndBottom()进行滑动;在AppBarLayout滑动的过程中,由于NestedScrollView中的ScrollingViewBehavior会依赖于AppBarLayout,所以在AppBarLayout滑动时候,NestedScrollView也会随着滑动,调用的方法也是ViewCompat.offsetTopAndBottom()

接下来再看下fling过程,fling过程在手指离开时候会判断调用,即从ACTION_UP开始:

case MotionEvent.ACTION_UP:
                if (mVelocityTracker != null) {
                    mVelocityTracker.addMovement(ev);
                    mVelocityTracker.computeCurrentVelocity(1000);
                    float yvel = mVelocityTracker.getYVelocity(mActivePointerId);
                    fling(parent, child, -getScrollRangeForDragFling(child), 0, yvel);
                }

可以看到直接调用了fling(...)中:

final boolean fling(CoordinatorLayout coordinatorLayout, V layout, int minOffset,
            int maxOffset, float velocityY) {
			//重置FlingRunnable
        if (mFlingRunnable != null) {
            layout.removeCallbacks(mFlingRunnable);
            mFlingRunnable = null;
        }

        if (mScroller == null) {
            mScroller = new OverScroller(layout.getContext());
        }

        mScroller.fling(
                0, getTopAndBottomOffset(), // curr
                0, Math.round(velocityY), // velocity.
                0, 0, // x
                minOffset, maxOffset); // 最大距离不超过AppbarLayout的高度

        if (mScroller.computeScrollOffset()) {
            mFlingRunnable = new FlingRunnable(coordinatorLayout, layout);
            ViewCompat.postOnAnimation(layout, mFlingRunnable);
            return true;
        } else {
            onFlingFinished(coordinatorLayout, layout);
            return false;
        }
    }

代码也比较简单,主要通过FlingRunnable循环调用setHeaderTopBottomOffset()方法就把AppBarLayout进行了View的移动:

private class FlingRunnable implements Runnable {
        private final CoordinatorLayout mParent;
        private final V mLayout;

        FlingRunnable(CoordinatorLayout parent, V layout) {
            mParent = parent;
            mLayout = layout;
        }

        @Override
        public void run() {
            if (mLayout != null && mScroller != null) {
                if (mScroller.computeScrollOffset()) {
                    setHeaderTopBottomOffset(mParent, mLayout, mScroller.getCurrY());
                    ViewCompat.postOnAnimation(mLayout, this);
                } else {
                    onFlingFinished(mParent, mLayout);
                }
            }
        }
    }

再看下fling完后做了什么,这里从上述代码可以看到调用了onFlingFinished(mParent, mLayout)AppBarLayout.Behavior中实现了当前方法:

@Override
        void onFlingFinished(CoordinatorLayout parent, AppBarLayout layout) {
            // At the end of a manual fling, check to see if we need to snap to the edge-child
            snapToChildIfNeeded(parent, layout);
        }

snapToChildIfNeeded(...)方法会根据scrollFlags来进行处理,由于在上面xml中使用的是layout_scrollFlags=scroll,所以在 当前方法中并不会进行对应的逻辑处理,那么fling操作到此也完成了,这里看到fling()操作只建立在AppBarLayout上,也就是说无论我们多快速滑动,始终在AppBarLayout到达最大滑动距离,也就是AppBarLayout高度时候滑动就会停止,不会去联动NestedScrollView。

当手指触摸NestedScrollView时的滑动逻辑。

接下来来看当手指触摸NestedScrollView时的滑动逻辑,在CoordinatorLayout四部曲学习之一:Nest接口的实现 原 文章中分析过,NestedScrollView作为子View滑动时候会首先调用startNestedScroll(...)方法来询问父View即CoordinatorLayout是否需要消费事件,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且AppBarLayout的高度不等于0且NestedScrollView可以滑动,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;
        }

上述Demo满足started=true,所以说明CoordinatorLayout需要进行消费事件的处理,然后回调AppBarLayout.Behavior.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();//getTotalScrollRange返回child的高度
                    max = min + child.getDownNestedPreScrollRange();//getDownNestedPreScrollRange()返回0
                } else {
                    // 手指向上滑动
                    min = -child.getUpNestedPreScrollRange();//同getTotalScrollRange
                    max = 0;
                }
                if (min != max) {
				//计算消费的距离
                    consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
                }
            }
        }

上面代码中出现了许多get....Range()方法主要是为了在我们使用对应LayoutParam.scrollflage=COLLAPSED相关标志的时候会使用到,由于我们分析代码不涉及到,所以都是返回的AppBarLayout的滑动高度或者0,上面代码已经注释了。接下来计算comsumed[1]:

final int scroll(CoordinatorLayout coordinatorLayout, V header,
            int dy, int minOffset, int maxOffset) {
        return setHeaderTopBottomOffset(coordinatorLayout, header,
                getTopBottomOffsetForScrollingSibling() - dy, minOffset, maxOffset);
    }

这个方法上面已经分析过了,重新贴下:

@Override
   //newOffeset传入了dy,也就是我们手指移动距离上一次移动的距离,
   //minOffset等于AppBarLayout的负的height,maxOffset等于0。
        int setHeaderTopBottomOffset(CoordinatorLayout coordinatorLayout,
                AppBarLayout appBarLayout, int newOffset, int minOffset, int maxOffset) {
            final int curOffset = getTopBottomOffsetForScrollingSibling();//获取当前的滑动Offset
            int consumed = 0;
			//AppBarLayout滑动的距离如果超出了minOffset或者maxOffset,则直接返回0
            if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
               //矫正newOffset,使其minOffset<=newOffset<=maxOffset
                newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
				//由于默认没设置Interpolator,所以interpolatedOffset=newOffset;
                if (curOffset != newOffset) {
                    final int interpolatedOffset = appBarLayout.hasChildWithInterpolator()
                            ? interpolateOffset(appBarLayout, newOffset)
                            : newOffset;
					//调用ViewOffsetBehvaior的方法setTopAndBottomOffset(...),最终通过
					//ViewCompat.offsetTopAndBottom()移动AppBarLayout
                    final boolean offsetChanged = setTopAndBottomOffset(interpolatedOffset);

                    //记录下消费了多少的dy。
                    consumed = curOffset - newOffset;
                   //没设置Interpolator的情况, mOffsetDelta永远=0
                    mOffsetDelta = newOffset - interpolatedOffset;
					....
                     //分发回调OnOffsetChangedListener.onOffsetChanged(...)
                    appBarLayout.dispatchOffsetUpdates(getTopAndBottomOffset());

                  
                    updateAppBarLayoutDrawableState(coordinatorLayout, appBarLayout, newOffset,
                            newOffset < curOffset ? -1 : 1, false);
                }
           ...
            return consumed;
        }

consumed的值有两种情况:

  • 当滑动的距离在minOffset和maxOffset区间之内,则consume!=0,也就说明需要AppBarLayout进行消费,这里对应着AppBarLayout还没移出我们的视线时候的消费情况。
  • 当滑动的距离超出了minOffset或者maxOffset后,则consume==0,也就说明需要AppBarLayout不进行消费了,这里对应着AppBarLayout移出我们的视线时候的消费情况。

回到AppBarLayout.Behavior中继续看相关方法:

@Override
        public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
                View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
                int type) {
				//这个方法是做个兼容,在Demo中倒是没试出来调用时机,选择性忽略
            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);//这个方法内部逻辑不会走,原因是scroll_flag=scroll
            }

            // Keep a reference to the previous nested scrolling child
            mLastNestedScrollingChildRef = new WeakReference<>(target);
        }

上面的方法比较简单,就不介绍了,接下来看下手指离开时候的处理,这时候应该回调对应Behavior的fling()方法,但是AppBarLayout在ACTION_UP这里并没有做多余的处理,甚至连fling相关回调都没调用,那只能从NestedScrollView的computeScroll()方法研究了:

public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            final int x = mScroller.getCurrX();
            final int y = mScroller.getCurrY();

            int dy = y - mLastScrollerY;

            // Dispatch up to parent
            if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)) {
                dy -= mScrollConsumed[1];
            }

            if (dy != 0) {
                final int range = getScrollRange();
                final int oldScrollY = getScrollY();

                overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);

                final int scrolledDeltaY = getScrollY() - oldScrollY;
                final int unconsumedY = dy - scrolledDeltaY;

                if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null,
                        ViewCompat.TYPE_NON_TOUCH)) {
                    final int mode = getOverScrollMode();
                    final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
                            || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
                    if (canOverscroll) {
                        ensureGlows();
                        if (y <= 0 && oldScrollY > 0) {
                            mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                        } else if (y >= range && oldScrollY < range) {
                            mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                        }
                    }
                }
            }

            // Finally update the scroll positions and post an invalidation
            mLastScrollerY = y;
            ViewCompat.postInvalidateOnAnimation(this);
        } else {
            // We can't scroll any more, so stop any indirect scrolling
            if (hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
                stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
            }
            // and reset the scroller y
            mLastScrollerY = 0;
        }
    }

一看到这我们就明白了,其实fling也就是对应的由手机来模拟我们触摸的过程,所以回调调用dispatchNestedPreScroll()dispatchNestedScroll()来进行通知AppBarLayout进行滑动,滑动的过程还是上面那一套,手指向下滑动时,当NestedScrollView滑动到顶的时候,就交付消费dy给AppBarLayout处理,而手指向上滑动时候则相反。