AppBarLayout设计的主要目的,我个人认为有以下几个:
- 配合ScrollableView如NestedScrollView、RecyclerView等完成嵌套滑动功能。制造滑动的主动权是在ScrollableView上。
- 制定了ScrollableView依赖于AppBarLayout的规则,当AppBarLayout主动滑动时,ScollableView能够根据AppBarLayout的位置,调整自身的位置。与1相对应,制造滑动的主动权在AppBarLayout上。
- AppBarLayout继承于LinearLayout,它通过对子View设置scroll相关的标志,来控制子View是否跟随滑动、上滑的时候是否吸顶、下滑的时候是否优先跟随滑动。
- 对外暴露了OnOffsetChangedListener,以便更灵活地实现AppBarLayout本身不能做到的一些功能。
本文主要会围绕这几个点,结合源码讲解AppBarLayout。
1. 从LinearLayout的子类角度讲解看AppBarLayout
我们都知道AppBarLayout类是继承了LinearLayout类的,并且设置为垂直方向的。对于LinearLayout,大家都很熟悉了。在AppBarLayout的篇幅中,我觉得还是需要强调几个知识点的,虽然很简单,但是依然有一些很重要的细节会被忽略掉。
- 假设AppBarLayout有四个子View,view1、view2、view3、view4。view4是绘制在最上面的,view1是绘制在最下面的。我们可能都知道,对子view设置app:layout_scrollFlags="scroll"可以让子view滑出屏幕。我们可以设置view1的属性app:layout_scrollFlags="scroll"。但是如果我们只设置view2的layout_scrollFlags="scroll",那么view2的该属性相当于没有设置。原因在于,假设view2可以滑动出屏幕,那么它势必会与view1产生交集,而且会绘制在view1上面,这样的效果很丑,google大神在设计的时候规避掉了这种不好的用户体验。如果子View没有设置scroll标志,那么它后面的兄弟,即使设置了scroll标志,也是无效的。getTotalScrollRange方法是计算AppBarLayout可以滑动出屏幕的距离。我们可以看到如果不满足(flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0条件,循环是会被break掉的,后面的子view根本都不参与计算,系统代码如下:
public final int getTotalScrollRange() { if (totalScrollRange != INVALID_SCROLL_RANGE) { return totalScrollRange; } 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.scrollFlags; 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 (i == 0 && ViewCompat.getFitsSystemWindows(child)) { // If this is the first child and it wants to handle system windows, we need to make // sure we don't scroll it past the inset range -= getTopInset(); } 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 totalScrollRange = Math.max(0, range); }
- AppBarLayout的onMeasure方法,比较普通说白了就是沿用了LinearLayout的测量思路。但是为什么要在这里提它呢,因为与它对应的ScrollableView对应的ScrollingViewBehavior的测量方法还是比较重要的,后面我们会讲
- AppBarLayout的onLayout方法,比较普通,说白了就是沿用了LinearLayout的layout思路。在这里提它的原因同2。
2. AppBarLayout对事件的处理
AppBarLayout有一个默认的Behavior,AppBarLayout$BaseBehavior,继承自com.google.android.material.appbar.HeaderBehavior,该类的主要作用就是处理触摸事件的。
//com.google.android.material.appbar.HeaderBehavior @Override public boolean onTouchEvent( @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev) { if (touchSlop < 0) { touchSlop = ViewConfiguration.get(parent.getContext()).getScaledTouchSlop(); } 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; if (!isBeingDragged && Math.abs(dy) > touchSlop) { isBeingDragged = true; if (dy > 0) { dy -= touchSlop; } else { dy += touchSlop; } } if (isBeingDragged) { lastMotionY = y; // We're being dragged so scroll the ABL scroll(parent, child, dy, getMaxDragOffset(child), 0); } break; } return true; }
我们可以看到处理Move事件时,会调用scroll方法,顾名思义就是让AppBarLayout滑出屏幕或者滑入屏幕。
final int scroll( CoordinatorLayout coordinatorLayout, V header, int dy, int minOffset, int maxOffset) { return setHeaderTopBottomOffset( coordinatorLayout, header, getTopBottomOffsetForScrollingSibling() - dy, minOffset, maxOffset); }
scroll方法主要是通过offsetTopAndBottom来实现偏移的。而且该方法,会有返回值,主要是处理ScrollableView发起的嵌套滑动用的,但是在这里,没有嵌套滑动的逻辑需要处理。
一般情况下,我们使用AppBarLayout和RecyclerView的时候,它们的布局总是前者在后者的上面。那么问题来了,AppBarLayout滑出了屏幕,如果RecyclerView不作出相应的改变,那么它们中间势必会有一段空白,这显然是不合理的,那么AppBarlayout是如何规避这个问题的。答案是通过CoordinatorLayout的依赖关系和AppBarLayout$ScrollingViewBehavior。
3. ScrollingViewBehavior为RecyclerView测量、Layout、跟随ABL滑动
ScrollingViewBehavior主要作用就是三个
- 跟随APL滑动
//ScrollingViewBehavior.java @Override public boolean onDependentViewChanged( @NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { offsetChildAsNeeded(child, dependency); updateLiftedStateIfNeeded(child, dependency); return false; } //根据APL的位置移动ScrollableView private void offsetChildAsNeeded(@NonNull View child, @NonNull View dependency) { final CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior(); if (behavior instanceof BaseBehavior) { // Offset the child, pinning it to the bottom the header-dependency, maintaining // any vertical gap and overlap final BaseBehavior ablBehavior = (BaseBehavior) behavior; ViewCompat.offsetTopAndBottom( child, (dependency.getBottom() - child.getTop()) + ablBehavior.offsetDelta + getVerticalLayoutGap() - getOverlapPixelsForOffset(dependency)); } }
为ScrollableView测量高度,由父类HeaderScrollingViewBehavior实现,主要的算法是,ScrollableView本身测量的高度-APL的高度+APL可滑动的距离,这个细节还是蛮重要的,想想为什么要这么设计。因为必须要加上APL可滑动的距离,否则,往上滑的时候,ScrollableView的高度不够,会出现白色的真空地带,影响用户体验。
//HeaderScrollingViewBehavior @Override public boolean onMeasureChild( @NonNull CoordinatorLayout parent, @NonNull View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { final int childLpHeight = child.getLayoutParams().height; if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT || childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT) { // If the menu's height is set to match_parent/wrap_content then measure it // with the maximum visible height final List<View> dependencies = parent.getDependencies(child); final View header = findFirstDependency(dependencies); if (header != null) { int availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec); if (availableHeight > 0) { if (ViewCompat.getFitsSystemWindows(header)) { final WindowInsetsCompat parentInsets = parent.getLastWindowInsets(); if (parentInsets != null) { availableHeight += parentInsets.getSystemWindowInsetTop() + parentInsets.getSystemWindowInsetBottom(); } } } else { // If the measure spec doesn't specify a size, use the current height availableHeight = parent.getHeight(); } int height = availableHeight + getScrollRange(header); 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; }
3.把ScrollableView布局在APL下方,代码比较简单,主要是计算位置。
//HeaderScrollingViewBehavior @Override protected void layoutChild( @NonNull final CoordinatorLayout parent, @NonNull final View child, final int layoutDirection) { final List<View> dependencies = parent.getDependencies(child); 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, header.getBottom() + lp.topMargin, parent.getWidth() - parent.getPaddingRight() - lp.rightMargin, parent.getHeight() + header.getBottom() - parent.getPaddingBottom() - lp.bottomMargin); final WindowInsetsCompat parentInsets = parent.getLastWindowInsets(); if (parentInsets != null && ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) { // If we're set to handle insets but this child isn't, then it has been measured as // if there are no insets. We need to lay it out to match horizontally. // Top and bottom and already handled in the logic above available.left += parentInsets.getSystemWindowInsetLeft(); available.right -= parentInsets.getSystemWindowInsetRight(); } final Rect out = tempRect2; 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; } }
4. AppBarLayout的嵌套滑动
AppBarLayout嵌套滑动,指的是当滚动下方ScrollableView时,AppBarLayout会跟随滑动。主要有三种情况:
- ScrollableView向上滑动时,ABL跟随滑动
- ScrollableView向下滑动时,ABL跟随滑动
- ScrollableView在顶部,向下滑动时,ABL处理ScrollableView无法处理的滑动
以上Case1、Case2 对应的方法是AppBarLayout
BaseBehavior#onNestedScroll。
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) { // We're scrolling down min = -child.getTotalScrollRange(); max = min + child.getDownNestedPreScrollRange(); } else { // We're scrolling up min = -child.getUpNestedPreScrollRange(); max = 0; } if (min != max) { consumed[1] = scroll(coordinatorLayout, child, dy, min, max); } } if (child.isLiftOnScroll()) { child.setLiftedState(child.shouldLift(target)); } }
当ScrollableView向上滑动时,ABL滑动距离在[-child.getUpNestedPreScrollRange(),0]范围内,0表示恢复原状,-child.getUpNestedPreScrollRange()表示滑出屏幕的距离,getUpNestedPreScrollRange()的值等于getTotalScrollRange()的值
int getUpNestedPreScrollRange() { return getTotalScrollRange(); }
当ScrollableView向下滑动时,ABL滑动距离在[-child.getTotalScrollRange(),-child.getTotalScrollRange()+child.getDownNestedPreScrollRange()]范围内。
int getDownNestedPreScrollRange() { if (downPreScrollRange != INVALID_SCROLL_RANGE) { // If we already have a valid value, return it return downPreScrollRange; } 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.scrollFlags; if ((flags & LayoutParams.FLAG_QUICK_RETURN) == LayoutParams.FLAG_QUICK_RETURN) { // First take the margin into account int childRange = 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 childRange += ViewCompat.getMinimumHeight(child); } else if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) { // Only enter by the amount of the collapsed height childRange += childHeight - ViewCompat.getMinimumHeight(child); } else { // Else use the full height childRange += childHeight; } if (i == 0 && ViewCompat.getFitsSystemWindows(child)) { // If this is the first child and it wants to handle system windows, we need to make // sure we don't scroll past the inset childRange = Math.min(childRange, childHeight - getTopInset()); } range += childRange; } 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 downPreScrollRange = Math.max(0, range); } }
getUpNestedPreScrollRange和getDownNestedScrollRange的区别是,getUpNestedPreScrollRange是从第一个View开始遍历,getDownNestedScrollRange是从第最后一个View开始遍历计算距离。
- ABL处理ScrollableView无法处理的滑动在onNestedScroll方法中,只在ScrollableView向下滑动时会触发。
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) { // If the scrolling view is scrolling down but not consuming, it's probably be at // the top of it's content consumed[1] = scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0); } }
int getDownNestedScrollRange() { if (downScrollRange != INVALID_SCROLL_RANGE) { // If we already have a valid value, return it return downScrollRange; } int range = 0; for (int i = 0, z = getChildCount(); i < z; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); int childHeight = child.getMeasuredHeight(); childHeight += lp.topMargin + lp.bottomMargin; final int flags = lp.scrollFlags; if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) { // We're set to scroll so add the child's height range += childHeight; if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) { // For a collapsing exit scroll, we to take the collapsed height into account. // We also break the range 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 downScrollRange = Math.max(0, range); }
5. AppBarLayout Scroll相关的flag详解
前文我们看到在getDownNestedPreScrollRange等方法中,通过遍历子view,判断lp.scrollFlags等标志来计算偏移量。那么下面具体讲讲这些flag的作用
flag | 值 | 含义 |
SCROLL_FLAG_NO_SCROLL | 0x0 | 子View不允许滑动,默认值 |
SCROLL_FLAG_SCROLL | 0x1 | 子View允许滑动,如果该子View前面的兄弟View没有设置该flag,标志位失效 |
SCROLL_FLAG_EXIT_UNTIL_COLLAPSED | 1 << 1 | 子View向上滑动出屏幕时,会有mininumHeight的高度吸顶,它后面的子View的flag失效 |
SCROLL_FLAG_ENTER_ALWAYS | 1 << 2 | |
SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED | 1 << 3 | |
SCROLL_FLAG_SNAP | 1 << 4 | |
SCROLL_FLAG_SNAP_MARGINS | 1 << 5 |
相关组合
组合 | 值 |
FLAG_QUICK_RETURN | SCROLL_FLAG_SCROLL | SCROLL_FLAG_ENTER_ALWAYS |
FLAG_SNAP | SCROLL_FLAG_SCROLL | SCROLL_FLAG_SNAP |
COLLAPSIBLE_FLAGS | SCROLL_FLAG_EXIT_UNTIL_COLLAPSED | SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED |
源码使用场景
场景一、 向上滑动,ABL跟随滑动时,会判断SCROLL_FLAG_SCROLL和SCROLL_FLAG_EXIT_UNTIL_COLLAPSED。children从前往后遍历,如果没有设置SCROLL_FLAG_SCROLL,会中断遍历,如果设置了SCROLL_FLAG_SCROLL,可滑动距离+child.getMeasureheight,如果同时设置了SCROLL_FLAG_EXIT_UNTIL_COLLAPSED,也会中断遍历,滑动距离-child.getMinimumHeight,child.getMinimumHeight会一直停留在屏幕中。
代码片段如下
public final int getTotalScrollRange() { if (totalScrollRange != INVALID_SCROLL_RANGE) { return totalScrollRange; } 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.scrollFlags; 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 (i == 0 && ViewCompat.getFitsSystemWindows(child)) { // If this is the first child and it wants to handle system windows, we need to make // sure we don't scroll it past the inset range -= getTopInset(); } 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 totalScrollRange = Math.max(0, range); }
场景二、 ScrollableView向下滑动时,ABL跟随滑动,会判断FLAG_QUICK_RETURN,SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED,SCROLL_FLAG_EXIT_UNTIL_COLLAPSED,children从后往前判断。
- FLAG_QUICK_RETURN,设置了该flag 向下滑的时候,会跟随滑出一段距离。距离由SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED、SCROLL_FLAG_EXIT_UNTIL_COLLAPSED决定
- 如果设置了SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED,滑出距离为ViewCompat.getMinimumHeight(child)
- 如果不满足条件2,但是设置了SCROLL_FLAG_EXIT_UNTIL_COLLAPSED,滑出距离childHeight - ViewCompat.getMinimumHeight(child),但是由于设置了该flag 会有ViewCompat.getMinimumHeight(child)吸顶,效果等同于全部滑出。
- 如果不满足条件2和条件3,滑动距离为childHeight
- FLAG_QUICK_RETURN与SCROLL_FLAG_SCROLL不一样。SCROLL_FLAG_SCROLL从前往后遍历,一旦遇到没设置的就中断遍历了。FLAG_QUICK_RETURN从后往前遍历,遇到没设置的不会中断遍历,除非曾经遇到过设置过该Flag而且滑动距离>0的情况会中断遍历(很绕,看源码,多假设场景)
int getDownNestedPreScrollRange() { if (downPreScrollRange != INVALID_SCROLL_RANGE) { // If we already have a valid value, return it return downPreScrollRange; } 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.scrollFlags; if ((flags & LayoutParams.FLAG_QUICK_RETURN) == LayoutParams.FLAG_QUICK_RETURN) { // First take the margin into account int childRange = 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 childRange += ViewCompat.getMinimumHeight(child); } else if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) { // Only enter by the amount of the collapsed height childRange += childHeight - ViewCompat.getMinimumHeight(child); } else { // Else use the full height childRange += childHeight; } if (i == 0 && ViewCompat.getFitsSystemWindows(child)) { // If this is the first child and it wants to handle system windows, we need to make // sure we don't scroll past the inset childRange = Math.min(childRange, childHeight - getTopInset()); } range += childRange; } 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 downPreScrollRange = Math.max(0, range); }
场景三、 ScrollableView在顶部,向下滑动时,ABL处理ScrollableView无法处理的滑动。该场景与场景一一样。只会判断SCROLL_FLAG_SCROLL和SCROLL_FLAG_EXIT_UNTIL_COLLAPSED。从前往后遍历。
int getDownNestedScrollRange() { if (downScrollRange != INVALID_SCROLL_RANGE) { // If we already have a valid value, return it return downScrollRange; } int range = 0; for (int i = 0, z = getChildCount(); i < z; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); int childHeight = child.getMeasuredHeight(); childHeight += lp.topMargin + lp.bottomMargin; final int flags = lp.scrollFlags; if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) { // We're set to scroll so add the child's height range += childHeight; if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) { // For a collapsing exit scroll, we to take the collapsed height into account. // We also break the range 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 downScrollRange = Math.max(0, range); }