1、关于 Bottom Sheet

Bottom Sheet 在Android Design Support Library 23.2 版本引入,翻译过来即底部动作条的意思,能够设置最小高度和最大高度 ,履行进入/退出动画,响应拖动/滑动手势等,首要用于完成从底部弹出一个对话框的作用。作用如下:

探索BottomSheet的背后秘密
探索BottomSheet的背后秘密
探索BottomSheet的背后秘密

一个合理的半屏弹出容器应该具备以下功用:

  • 支撑进出滑动动画及手动滑动拖拽

  • 处理滑动抵触

在 Google 官方推出 Bottom Sheet 之前,在 Github 上面已经有一些开源的库完成相似的作用。在此之后由于BottomSheet能满意大部分半屏诉求,因而业界普遍遵从官方Material Design设计规范,运用官方组件来完成半屏弹出或滑动拖拽作用。

  • AndroidSlidingUpPanel
  • bottomsheet
  • BottomSheet

Bottom Sheet 具体完成首要包含:BottomSheetBeahvior 、BottomSheetDialog、BottomSheetDialogFragment,这三个组件均能够完成半屏弹出作用,差异点在于接入和运用办法上的差异。本文要点剖析BottomSheetBeahvior,其余两个均是依据BottomSheetBeahvior所完成,只做简略阐明,不详细打开:

  • BottomSheetBeahvior 一般直接作用在view上,一般在xml布局文件中直接对view设置属性,轻量级、代码侵略低、灵活性高,适用于杂乱页面下的半屏弹出作用。 app:layout_behavior=”@string/bottom_sheet_behavior”

  • BottomSheetDialog 的运用和对话框的运用基本上是相同的。经过setContentView()设定布局,调用show()展示即可。由于必须要运用Dialog,运用上局限相对多,因而一般适用于底部弹出的轻交互弹窗,如底部阐明弹窗等。

  • BottomSheetDialogFragment 的运用同一般的Fragment相同,能够将交互和UI写到Fragment内,适合一些有简略交互的弹窗场景,如底部共享弹窗面板等。

2、什么是Behavior

Behavior是Android Support Design库里边新增的布局概念,首要的作用是用来协调CoordinatorLayout布局直接Child Views之间布局及交互行为的,包含拖拽、滑动等各种手势行为。

可是Behavior只能作用于CoordinatorLayout的直接Child View.

e.g. 以下代码是设置给FrameLayout,而不是CoordinatorLayout

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.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">
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/test_behavior" />
</android.support.design.widget.CoordinatorLayout>

behaior的简略运用场景:如完成下图FloatingActionButton的上滑躲藏、下滑显现,完成参阅:guides.codepath.com/android/flo…

探索BottomSheet的背后秘密

2.1 测量和布局

CoordinatorLayout的onMeasure和onLayout 均代理给Behavior完成。

探索BottomSheet的背后秘密

onMeasure

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ......
    for (int i = 0; i < childCount; i++) {
        final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
        ......
        final CoordinatorLayout.Behavior b = lp.getBehavior();
        if (b == null || !b.onMeasureChild(this, child, childWidthMeasureSpec, keylineWidthUsed,
                                           childHeightMeasureSpec, 0)) {
            onMeasureChild(child, childWidthMeasureSpec, keylineWidthUsed,
                           childHeightMeasureSpec, 0);
        }
        ......
    }
    ......
}

onLayout

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    ......
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        ......
        final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
        final CoordinatorLayout.Behavior behavior = lp.getBehavior();
        if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
            onLayoutChild(child, layoutDirection);
        }
    }
}

2.2 一般接触事情

CoordinatorLayout的onInterceptTouchEvent和onTouchEvent 是经过遍历CoordinatorLayout的子View,找到第一个关联Behavior的 onInterceptTouchEvent和onTouchEvent 回来true的Child View,并交给其Beahvior履行,假如没有找到,则交由CoordinatorLayout本身处理。

onInterceptTouchEvent:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    MotionEvent cancelEvent = null;
    final int action = MotionEventCompat.getActionMasked(ev);
    // Make sure we reset in case we had missed a previous important event.
    if (action == MotionEvent.ACTION_DOWN) {
        resetTouchBehaviors();
    }
    final boolean intercepted = performIntercept(ev, TYPE_ON_INTERCEPT);
    if (cancelEvent != null) {
        cancelEvent.recycle();
    }
    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
        resetTouchBehaviors();
    }
    return intercepted;
}
private boolean performIntercept(MotionEvent ev, final int type) {
    boolean intercepted = false;
    boolean newBlock = false;
    MotionEvent cancelEvent = null;
    final int action = MotionEventCompat.getActionMasked(ev);
    final List<View> topmostChildList = 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;
}

onTouchEvent:

@Override
public boolean onTouchEvent(MotionEvent ev) {
    boolean handled = false;
    boolean cancelSuper = false;
    MotionEvent cancelEvent = null;
    final int action = MotionEventCompat.getActionMasked(ev);
    if (mBehaviorTouchView != null || (cancelSuper = performIntercept(ev, TYPE_ON_TOUCH))) {
        // Safe since performIntercept guarantees that
        // mBehaviorTouchView != null if it returns true
        final LayoutParams lp = (LayoutParams) mBehaviorTouchView.getLayoutParams();
        final Behavior b = lp.getBehavior();
        if (b != null) {
            handled = b.onTouchEvent(this, mBehaviorTouchView, ev);
        }
    }
    // Keep the super implementation correct
    if (mBehaviorTouchView == null) {
        handled |= super.onTouchEvent(ev);
    } else if (cancelSuper) {
        if (cancelEvent != null) {
            final long now = SystemClock.uptimeMillis();
            cancelEvent = MotionEvent.obtain(now, now,
                    MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
        }
        super.onTouchEvent(cancelEvent);
    }
    if (!handled && action == MotionEvent.ACTION_DOWN) {
    }
    if (cancelEvent != null) {
        cancelEvent.recycle();
    }
    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
        resetTouchBehaviors();
    }
    return handled;
}

3、BottomSheetBehavior布局介绍

从姓名即能够看出,BottomSheetBehavior承继自CoordinatorLayout.Behavior,借用behavior的布局和事情分发才能来完成底部弹出动画及手势拖拽作用。下面首要剖析下bottomsheet初始弹出时是怎么完成弹出动画。

一个简略的半屏滑动布局如下:

探索BottomSheet的背后秘密

3.1 BottomSheetBehavior的几种状况

  • STATE_HIDDEN :躲藏状况,关联的View此刻并不是GONE,而是此刻在屏幕最下方之外,此刻仅仅无法肉眼看到

探索BottomSheet的背后秘密

  • STATE_COLLAPSED :折叠状况,一般是一种半屏形状

探索BottomSheet的背后秘密

  • STATE_EXPANDED:彻底打开,彻底打开的高度是可装备,默许即屏幕高度。相似地图主页一般彻底打开态的高度装备为间隔屏幕高差一小截间隔。

探索BottomSheet的背后秘密

  • STATE_DRAGGING:拖拽状况,标识人为手势拖拽中(手指未脱离屏幕)

  • STATE_SETTLING :视图从脱离手指自由滑动到最终停下的这一小段时刻,与STATE_DRAGGING差异在于当时并没有手指在拖拽。首要表达两种场景:初始弹出时动画状况、手指手动拖拽释放后的滑动状况。

3.2 BottomSheetBehavior的初始弹出

一般BottomSheetBehavior运用的场景为从底部弹出,这种场景下,当设置STATE_COLLAPSED状况时,经历了STATE_HIDDEN -> STATE_SETTLING -> STATE_COLLAPSED 改变。

初始动画的弹出是有Scroller + ViewCompat.offsetLeftAndRight 配合来完成view 移动动画。

首要过程为:

1、设置STATE_COLLAPSED状况,触发view动画逻辑,将View从屏幕外移动到屏幕内

2、动画逻辑为 首要核算出需要移动的间隔,然后运用Scroller 设置动画时长后,开端履行scroll。要点在于Scroller仅仅一个表达位移值改变的辅助工具,它并不会履行实践的view移动

3、Scroller 开端移动后,一同会敞开一个线程,不断的监听当时Scroller的惟一间隔,并将当时View移动响应间隔(ViewCompat.offsetLeftAndRight)

探索BottomSheet的背后秘密

4、BottomSheetBehavior滑动

4.1、嵌套滑动NestedScroll

了解BottomSheet 的滑动咱们首要要了解下嵌套滑动,嵌套滑动是为了处理父view和子view 滑动抵触所提冲的一套机制。

一般的接触音讯的分发都是从外向内的,由外层的ViewGroup的dispatchTouchEvent办法调用到内层的View的dispatchTouchEvent办法.

而NestedScroll提供了一个反向的机制,内层的view在接收到ACTION_MOVE的时分,将翻滚音讯先传回给外层的ViewGroup,由外层的ViewGroup决定是不是需要耗费一部分的移动,然后内层的View再去耗费剩余的移动.内层view能够耗费剩余的翻滚的一部分,假如还没有耗费完,外层的view能够再挑选把最终剩余的翻滚耗费掉.

为了完成嵌套滑动,需要父View和子View别离完成NestedScrollingParent 和 NestedScrollingChild接口,来进行相关逻辑处理。

public interface NestedScrollingChild {
    public void setNestedScrollingEnabled(boolean enabled);
    public boolean isNestedScrollingEnabled();
    public boolean startNestedScroll(int axes);
    public void stopNestedScroll();
    public boolean hasNestedScrollingParent();
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
    public boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
public interface NestedScrollingParent {
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
    public void onStopNestedScroll(View target);
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);
    public boolean onNestedPreFling(View target, float velocityX, float velocityY);
    public int getNestedScrollAxes();
}

探索BottomSheet的背后秘密

  • onStartNestedScroll 是否承受嵌套翻滚,只要它回来true,后面的其他办法才会被调用

  • onNestedPreScroll 在内层view处理翻滚事情前先被调用,能够让外层view先耗费部分翻滚

  • onNestedScroll 在内层view将剩余的翻滚耗费完之后调用,能够在这儿处理最终剩余的翻滚

  • onNestedPreFling 在内层view的Fling事情处理之前被调用

  • onNestedFling 在内层view的Fling事情处理完之后调用

4.2、BottomSheetBehavior的滑动

BottomSheetBehavior的滑动分两种:一种是子view完成了NestScroll嵌套滑动(如RecyclerView)、一种是子view没有完成嵌套滑动(如webView)。

4.2.1、非嵌套滑动

4.2.1.1、从半屏滑动到全屏

BottomSheetBehavior 在半屏下,onToucInterceptTouchEvent 默许阻拦MOVE事情,则会走到behavior本身的onTouch事情,履行CoordinatorLayout容器view的本身滑动,滑动经过ViewCompat.offsetLeftAndRight 依据move事情移动间隔来完成。

4.2.1.2、全屏状况下滑动

在全屏状况下存在需要容器的滑动和内容滑动两种需求。此刻需要经过事情阻拦来完成,一般咱们常用的内部阻拦/外部阻拦。在Behavior场景下,更多采用内部阻拦,即子View监听onTouch事情,依据滑动场景调用requestDisallowInterceptTouchEvent 来完成容器滑动/内容滑动。


4.2.2、嵌套滑动

4.2.2.1、从半屏滑动到全屏

同非嵌套滑动

4.2.2.2、全屏状况下滑动

在子View有NestScroll时,滑动事情会先分发到子view,子view触发嵌套滑动,向上触发父view的onNestPreScroll,由父view优先进滑动的消费,onNestPreScroll会被CoordinatorLayout转发到Beahvior,由Behavior进行实践消费处理。

  • 向下滑动容器

当此刻子view无法手势向下互动时,BottomSheetBehavior会进行滑动间隔的消费,触发容器的滑动

  • 内容上下滑动

当子view能够让下滑动时,BottomSheetBehavior不进行滑动间隔的消费,由子view进行消费,完成子view内容的滑动。

 @Override
  public void onNestedPreScroll(
      @NonNull CoordinatorLayout coordinatorLayout,
      @NonNull V child,
      @NonNull View target,
      int dx,
      int dy,
      @NonNull int[] consumed,
      int type) {
    if (type == ViewCompat.TYPE_NON_TOUCH) {
      // Ignore fling here. The ViewDragHelper handles it.
      return;
    }
    View scrollingChild = mNestedScrollingChildRef.get();
    if (target != scrollingChild) {
      return;
    }
    int currentTop = child.getTop();
    int newTop = currentTop - dy;
    if (dy > 0) { // Upward
      if (newTop < getExpandedOffset()) {
        consumed[1] = currentTop - getExpandedOffset();
        ViewCompat.offsetTopAndBottom(child, -consumed[1]);
        setStateInternal(STATE_EXPANDED);
      } else {
        consumed[1] = dy;
        ViewCompat.offsetTopAndBottom(child, -dy);
        setStateInternal(STATE_DRAGGING);
      }
    } else if (dy < 0) { // Downward
      if (!target.canScrollVertically(-1)) {
        if (newTop <= mCollapsedOffset || mHideable) {
          consumed[1] = dy;
          ViewCompat.offsetTopAndBottom(child, -dy);
          setStateInternal(STATE_DRAGGING);
        } else {
          consumed[1] = currentTop - mCollapsedOffset;
          ViewCompat.offsetTopAndBottom(child, -consumed[1]);
          setStateInternal(STATE_COLLAPSED);
        }
      }
    }
    dispatchOnSlide(child.getTop());
    mLastNestedScrollDy = dy;
    mNestedScrolled = true;
  }

5、一些小坑

  • 初始弹出高度

布景:在页面初始打开时,咱们需要设置初始的弹出高度为Activity页面内容的百分比(80%),假如在onCreate中直接核算高度,此刻获取高度会得到错误的值。

处理:经过监听onGlobalLayout,在第一次回调时机时来进行核算,此刻Activity内容高度已确定。


  • 多个NestScroll child

布景:当页面内存在两个RecyclerView时(两个RecyclerView别离标识半屏和全屏下的列表,UI款式存在差异,在半/全屏上下滑动时进行透明度的改变,以显现不同作用),此刻会呈现滑动不收效或许紊乱。

处理:BottomSheetBehavior获取子view中的NestScrollChild是遍历子View取第一个NestScrollView,因而会导致NestScroll获取反常。

因而经过BottomSheetBehavior增加接口,自动标识当时场景下应该获取的NestScrollChild是哪一个。

同理假如BottomSheetBehavior嵌套ViewPage再嵌套R多个RecyclerView,也会存在相似问题,可用相似方案处理。

探索BottomSheet的背后秘密


  • 折叠态时初度滑动卡顿

布景:当页面内存在两个RecyclerView时(两个RecyclerView别离标识半屏和全屏下的列表,半屏下只显现半屏RecyclerView,全屏下只显现全屏RecyclerView,经过滑动进行透明度切换),当页面初始弹出到半屏状况后,手动向上滑动,会呈现明显的卡顿,之后第2次上下滑动即不再卡顿。

处理:一开端将排查要点放behavior本身逻辑上,可是咱们发现第2次onTouch事情间隔第一次onTouch事情回掉相差100ms左右,导致view拖拽动画呈现断层,这也是卡顿的直接原因。

onTouch回掉推迟,即表明第一次onTouch事情后发生发生了一些耗时操作,经过火焰图剖析咱们能够发现耗时操作大部分都是RecyclerView的item创建和绑定数据,到这儿大约就能够得出卡顿的原因:

  • 半屏页面初度弹出时显现的是半屏的RecyclerView,而全屏RecyclerView处于GONE状况,不会履行列表item的创建和绑定数据。
  • 当向上滑动时,咱们会一同动态改变半屏和全屏RecyclerView的透明度,来完成两种UI作用的切换
  • 当第一次onTouch事情回掉时,此刻触发列表的透明度改变,全屏RecyclerView开端变为VISIBLE状况,触发列表本身item的创建和绑定数据,这个过程是一个相对耗时的操作,且只能在UI线程进行,因而就导致后续onTouch事情被阻塞,发生卡顿。

处理方案:监听半屏列表渲染到屏幕后推迟100ms设置全屏RecyclerView为VISIBLE,可是此刻只给其设置一个极小的alpha,这即能够保证列表提早渲染,又不影响视觉显现作用。

经历:在bottomSheet 滑动过程中应该避免在主线程中处理耗时操作,否则会产生动画卡顿。

hi, 我是快手电商的小俊~

快手电商无线技能团队正在招贤纳士! 咱们是公司的核心事务线, 这儿云集了各路高手, 也充满了机会与应战. 伴随着事务的高速开展, 团队也在快速扩张. 欢迎各位高手加入咱们, 一同发明世界级的电商产品~

热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品司理(电商布景), 测试开发… 大量 HC 等你来呦~

内部引荐请发简历至 >>>咱们的邮箱: hr.ec@kuaishou.com <<<, 备注我的诨名成功率更高哦~