探索BottomSheet的背后秘密

1、关于 Bottom Sheet

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

5159beebaee269f2873e83cb020389aa.jpeg3487294e7fb38448b756d81641fb1b4e.jpegb3927dcaab2b4abfeff18f7db9cb0b48.jpeg

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

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

  • 处理滑动冲突

在 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的上滑隐藏、下滑显示,实现参考:

https://guides.codepath.com/android/floating-action-buttons

f3592371f1b9857a60098264668304cc.jpeg

2.1 测量和布局

CoordinatorLayout的onMeasure和onLayout 均代理给Behavior实现。

b44724f4af864875d71e36fa50672acd.jpeg

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初始弹出时是如何实现弹出动画。

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

ae4bd5418688e1edda8c75301c44b9a0.jpeg

3.1 BottomSheetBehavior的几种状态

  • STATE_HIDDEN :隐藏状态,关联的View此时并不是GONE,而是此时在屏幕最下方之外,此时只是无法肉眼看到

c22df1d8a3b75f41e1b671afd2fcebe4.jpeg

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

78d0f8dc11119f689958cd82f8bd6961.jpeg

  • STATE_EXPANDED:完全展开,完全展开的高度是可配置,默认即屏幕高度。类似地图首页一般完全展开态的高度配置为距离屏幕高差一小截距离。

e417a9b0deaff7b56c3452bf4e0bec29.jpeg

  • 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)

b715c57af06d9fdb281261b453f7fad7.jpeg

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();
}

69904d41ed2663bdd8bfe880c3cf59ef.jpeg

  • 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,也会存在类似问题,可用类似方案解决。

349d6e96edbf230ed72d143b16c18cee.jpeg

  • 折叠态时初次滑动卡顿

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

解决:一开始将排查重点放behavior自身逻辑上,但是我们发现第二次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 滑动过程中应该避免在主线程中处理耗时操作,否则会产生动画卡顿。

作者:快手电商无线团队
链接:https://juejin.cn/post/7156874737740677133

关注我获取更多知识或者投稿

c572d931b45b13b2e6aca13fb4949035.jpeg

ed7f3e79e464150242b8e5d16082c3a9.jpeg

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值