查看原文
其他

探索 BottomSheet 的背后原理

AndroidPub 2023-02-21

快手电商无线团队

https://juejin.cn/post/7156874737740677133

1. 关于 Bottom Sheet

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

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

  • 支持进出滑动动画及手动滑动拖拽
  • 处理滑动冲突

在 Google 官方推出 BottomSheet 之前,在 Github 上面已经有一些开源的库实现类似的效果。例如

  • https://github.com/umano/AndroidSlidingUpPanel
  • https://github.com/Flipboard/bottomsheet
  • https://github.com/soarcn/BottomSheet

在此之后因为 BottomSheet 能满足大部分半屏诉求,因此业界普遍遵循官方 Material Design 设计规范,使用官方组件来实现半屏弹出或滑动拖拽效果。

BottomSheet 具体实现主要包含:BottomSheetBeahviorBottomSheetDialogBottomSheetDialogFragment,这三个组件均可以实现半屏弹出效果,区别点在于接入和使用方式上的差异。本文重点分析 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

2.1 测量和布局

CoordinatorLayout 的 onMeasureonLayout 均代理给 Behavior 实现。

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 的 onInterceptTouchEventonTouchEvent 是通过遍历 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.0f0.0f0);
                }
                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.0f0.0f0);
        }
        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 初始弹出时是如何实现弹出动画。

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

3.1 BottomSheetBehavior的几种状态

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

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

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

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

4、BottomSheetBehavior滑动

4.1、嵌套滑动NestedScroll

理解 BottomSheet 的滑动我们首先要了解下嵌套滑动,嵌套滑动是为了解决父view和子view 滑动冲突所提冲的一套机制。

一般的触摸消息的分发都是从外向内的,由外层的 ViewGroup 的 dispatchTouchEvent 方法调用到内层的 View 的 dispatchTouchEvent 方法.

NestedScroll 提供了一个反向的机制,内层的 view 在接收到 ACTION_MOVE 的时候,将滚动消息先传回给外层的 ViewGroup ,由外层的 ViewGroup 决定是不是需要消耗一部分的移动,然后内层的 View 再去消耗剩下的移动。内层 view 可以消耗剩下的滚动的一部分,如果还没有消耗完,外层的 view 可以再选择把最后剩下的滚动消耗掉.

为了实现嵌套滑动,需要父View 和子View 分别实现 NestedScrollingParentNestedScrollingChild 接口,来进行相关逻辑处理。

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();
}
方法说明
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、一些小坑

5.1 初始弹出高度

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

解决:通过监听 onGlobalLayout,在第一次回调时机时来进行计算,此时 Activity 内容高度已确定。

5.2 多个 NestScroll child

背景:当页面内存在两个 RecyclerView 时(两个 RecyclerView 分别标识半屏和全屏下的列表,UI样式存在差异,在半/全屏上下滑动时进行透明度的变化,以显示不同效果),此时会出现滑动不生效或者错乱。

解决:BottomSheetBehavior 获取子view中的 NestScrollChild 是遍历子View取第一个 NestScrollView,因此会导致 NestScroll 获取异常。

因此通过 BottomSheetBehavior 增加接口,主动标识当前场景下应该获取的 NestScrollChild 是哪一个。同理如果 BottomSheetBehavior 嵌套 ViewPage 再嵌套多个 RecyclerView,也会存在类似问题,可用类似方案解决。

5.3 折叠态时初次滑动卡顿

背景:当页面内存在两个 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 滑动过程中应该避免在主线程中处理耗时操作,否则会产生动画卡顿。

-- END --

推荐阅读

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存