美文网首页Android开发经验谈android
BottomSheetXXX实现下滑关闭菜单踩坑记

BottomSheetXXX实现下滑关闭菜单踩坑记

作者: sollian | 来源:发表于2018-09-13 18:23 被阅读270次

    做开发时经常碰到底部菜单的需求。通常情况下,不需要支持手势滑动,只需要有滑动进入和滑动退出的效果即可。但有些时候,需要支持下滑关闭,这里我们来踩踩下滑关闭的那些坑。

    谈到手势下滑关闭,我们立即想到了BottomSheetBehaviorBottomSheetDialogBottomSheetDialogFragment这三个类。它们本质上都是由BottomSheetBehavior实现,而BottomSheetDialogBottomSheetDialogFragmentDialogDialogFragment的关系,所以我们仅以BottomSheetBehaviorBottomSheetDialogFragment两个类来分别考虑如何实现。

    下面开始探索之旅。以如下场景为例:

    点击页面按钮弹出底部菜单,首先展示商品种类的页面,点击某一种类后切换到某一类商品页面,点击back键或者返回按钮返回到商品种类页面。
    两个页面各包含一个列表,底部菜单支持嵌套滑动,下拉关闭。

    主页面布局如下:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#aaa"
        android:orientation="vertical"
        tools:context=".MainActivity">
    
        <Button
            android:id="@+id/bt1"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:onClick="openGoodsBehaviorFragment"
            android:text="openGoodsBehaviorFragment"
            android:textAllCaps="false" />
    
        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_below="@+id/bt1"
            android:onClick="openGoodsDialogFragment"
            android:text="openGoodsDialogFragment"
            android:textAllCaps="false" />
    
        <FrameLayout
            android:id="@+id/container"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </RelativeLayout>
    

    坑1 按钮在底部菜单之上

    非常简单的布局就碰到了一个坑,我们给FrameLayout设置一个蓝色背景android:background="#09c看下效果图:

    图1 第一个坑

    发生了什么情况?FrameLayout不应该在最上层吗,为什么两个按钮没有被覆盖?

    没有被覆盖的话,推测应该是按钮被设置了translationZ或者elevation这两个属性,然后顺着当前应用的styleTheme.AppCompat.Light.DarkActionBar一步步找到了如下代码:

    <style name="Base.V21.Theme.AppCompat.Light" parent="Base.V7.Theme.AppCompat.Light">
        ...
        <item name="buttonStyle">?android:attr/buttonStyle</item>
        ...
    </style>
    

    themes_material.xml中:

    <item name="buttonStyle">@style/Widget.Material.Button</item>
    

    继续找,在styles_material.xml中:

        <style name="Widget.Material.Button">
            <item name="background">@drawable/btn_default_material</item>
            <item name="textAppearance">?attr/textAppearanceButton</item>
            <item name="minHeight">48dip</item>
            <item name="minWidth">88dip</item>
            <item name="stateListAnimator">@anim/button_state_list_anim_material</item>
            <item name="focusable">true</item>
            <item name="clickable">true</item>
            <item name="gravity">center_vertical|center_horizontal</item>
        </style>
    

    然后在button_state_list_anim_material.xml中找到了目标:

    <selector xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:state_pressed="true" android:state_enabled="true">
            <set>
                <!-- 4dp -->
                <objectAnimator android:propertyName="translationZ"
                                android:duration="@integer/button_pressed_animation_duration"
                                android:valueTo="@dimen/button_pressed_z_material"
                                android:valueType="floatType"/>
                <!-- 2dp -->
                <objectAnimator android:propertyName="elevation"
                                android:duration="0"
                                android:valueTo="@dimen/button_elevation_material"
                                android:valueType="floatType"/>
            </set>
        </item>
        <!-- base state -->
        <item android:state_enabled="true">
            <set>
                <objectAnimator android:propertyName="translationZ"
                                android:duration="@integer/button_pressed_animation_duration"
                                android:valueTo="0"
                                android:startDelay="@integer/button_pressed_animation_delay"
                                android:valueType="floatType"/>
                <!-- 2dp -->
                <objectAnimator android:propertyName="elevation"
                                android:duration="0"
                                android:valueTo="@dimen/button_elevation_material"
                                android:valueType="floatType" />
            </set>
        </item>
        <item>
            <set>
                <objectAnimator android:propertyName="translationZ"
                                android:duration="0"
                                android:valueTo="0"
                                android:valueType="floatType"/>
                <objectAnimator android:propertyName="elevation"
                                android:duration="0"
                                android:valueTo="0"
                                android:valueType="floatType"/>
            </set>
        </item>
    </selector>
    

    elevation是绝对值,是View本身的属性,与left、top共同决定了View在三维空间的绝对位置。
    translationZ是相对于elevation的偏移量。同理,translationX是相对于left的偏移量,translationY是相对于top的偏移量。
    View的最终位置=绝对位置+偏移量

    至此,要解决上述问题,只需要保证FrameLayout的最终Z轴位置不小于按钮最终Z轴位置即可。由button_state_list_anim_material.xml可知,按钮按下状态,有最大Z轴位置6dp,所以可以为FrameLayout添加如下属性:

        <!-- 只需要保证elevation+translationZ>=6dp即可 -->
        android:elevation="6dp"
    

    使用BottomSheetBehavior实现下滑关闭的GoodsBehaviorFragment

    接下来研究如何通过BottomSheetBehavior实现下滑关闭。首先看一下BottomSheetBehavior的几种状态:

    • STATE_DRAGGING:拖动状态
    • STATE_SETTLING:松开手指后,自由滑动状态
    • STATE_EXPANDED:完全展开状态
    • STATE_COLLAPSED:折叠状态,或者称为半展开状态
    • STATE_HIDDEN:隐藏状态

    本例中,GoodsBehaviorFragment是用来展示商品信息的底部菜单,设置了BottomSheetBehavior,实现下滑关闭功能。该fragment包含了两个子fragment:GoodsTypeFragmentGoodsFragment,分别是商品种类fragment和某一种类的商品fragment。点击GoodsTypeFragment的一项,进入该种类的列表。两个子fragment各包含一个RecyclerView列表,所以还需要保证能够嵌套滑动(下滑关闭功能和列表的嵌套滑动)。

    坑2 菜单首次弹出显示不全

    BottomSheetBehavior默认是STATE_COLLAPSED,初次接触,总会被这个状态蹂躏一番。首先来看看这到底是个什么样的状态:

    图2 STATE_COLLAPSED 图3 STATE_EXPANDED

    显然,STATE_COLLAPSED不是我们想要的状态,在实现类GoodsBehaviorFragment做如下处理:

        @Override
        public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
            super.onViewCreated(view, savedInstanceState);
            ...
            behavior = BottomSheetBehavior.from((ViewGroup) view.findViewById(R.id.root));
            view.post(new Runnable() {
                @Override
                public void run() {
                    behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
                }
            });
            ...
        }
    

    这样在每次弹出时,便进入了STATE_EXPANDED状态。

    坑3 隐藏菜单时崩溃

    然而,当调用behavior.setState(BottomSheetBehavior.STATE_HIDDEN);来隐藏菜单时,发生了如下崩溃:

    图4 隐藏菜单时的崩溃信息

    崩溃处代码如下:

        void startSettlingAnimation(View child, int state) {
            int top;
            if (state == STATE_COLLAPSED) {
                top = mMaxOffset;
            } else if (state == STATE_EXPANDED) {
                top = mMinOffset;
            } else if (mHideable && state == STATE_HIDDEN) {
               //这是想要进入的隐藏状态
                top = mParentHeight;
            } else {
                throw new IllegalArgumentException("Illegal state argument: " + state);
            }
            if (mViewDragHelper.smoothSlideViewTo(child, child.getLeft(), top)) {
                setStateInternal(STATE_SETTLING);
                ViewCompat.postOnAnimation(child, new SettleRunnable(child, state));
            } else {
                setStateInternal(state);
            }
        }
    

    可见,想要进入state == STATE_HIDDEN这个分支,还需要mHideable==true才可以,所以设置如下方法:

    behavior.setHideable(true);
    

    坑4 下滑仍会进入STATE_COLLAPSED状态

    如上设置完毕,在菜单区域下滑,发现首先会进入STATE_COLLAPSED状态,如下:

    图5 下滑进入STATE_COLLAPSED状态

    再次下滑,才会隐藏。BottomSheetBehavior有如下方法判断是否该隐藏:

        boolean shouldHide(View child, float yvel) {
            if (mSkipCollapsed) {
                return true;
            }
            if (child.getTop() < mMaxOffset) {//这里进入到了折叠状态
                // It should not hide, but collapse.
                return false;
            }
            final float newTop = child.getTop() + yvel * HIDE_FRICTION;
            return Math.abs(newTop - mMaxOffset) / (float) mPeekHeight > HIDE_THRESHOLD;
        }
    

    针对这种情况,只需要保证mSkipCollapsed==true,需要设置如下方法:

    behavior.setSkipCollapsed(true);
    

    表示在隐藏时,跳过折叠状态,直接进入隐藏状态。

    坑5 菜单弹出时,不是从底部弹出的

    现象如下:

    图6 菜单不是从底部弹出

    上面提到过,BottomSheetBehavior的初始状态是折叠态,折叠态时,菜单的高度可以通过setPeekHeight方法设置。
    虽然我们不需要折叠状态,但因为折叠状态是默认态,所以即便我们一开始就设置了展开状态,实际上底部菜单是从折叠状态的高度(而非隐藏状态的0)过渡到展开状态的高度。
    所以为了达到我们想要的效果(菜单高度从0过渡到展开状态的高度),需要设置如下代码:

    behavior.setPeekHeight(0);
    

    设置完毕,再来看一下整体效果:

    图7 弹出、隐藏效果展示

    效果看起来不错,也可以下滑关闭。但到此就完事了吗?看一下嵌套滑动时的下滑关闭功能

    坑6 展示某类商品时,嵌套滑动失效

    展示商品种类列表时:

    图8 展示商品种类列表时可以嵌套滑动

    可以嵌套滑动,没问题。再看展示某类商品列表时:

    图9 展示某类商品列表时不可以嵌套滑动

    此时不可以嵌套滑动了。

    继续翻看BottomSheetBehavior源码,在onLayoutChild方法中有这么一句:

        @Override
        public boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection) {
            ...
            mViewRef = new WeakReference<>(child);
            mNestedScrollingChildRef = new WeakReference<>(findScrollingChild(child));
            return true;
        }
    

    mNestedScrollingChildRef用于保存嵌套滑动的子View(本例中是RecyclerView),由findScrollingChild方法提供:

        View findScrollingChild(View view) {
            if (ViewCompat.isNestedScrollingEnabled(view)) {
                return view;
            }
            if (view instanceof ViewGroup) {
                ViewGroup group = (ViewGroup) view;
                for (int i = 0, count = group.getChildCount(); i < count; i++) {
                    View scrollingChild = findScrollingChild(group.getChildAt(i));
                    if (scrollingChild != null) {
                        return scrollingChild;
                    }
                }
            }
            return null;
        }
    

    该方法递归查找,将找到的第一个RecyclerView返回。
    那么问题来了,在展示某类商品的列表GoodsFragment时,商品种类列表GoodsTypeFragment并没有被remove掉,也就是说同时存在两个RecyclerView,而从findScrollingChild的查找顺序看,总是会返回GoodsTypeFragment的列表,这才导致展示GoodsFragment时,不能嵌套滑动。

    找到了原因,这个问题就不难解决了。一种方式是每次只添加一个fragment,自然不会存在多个RecyclerView的情况。但很多时候我们是需要两个fragment共存的。这时可以通过反射来修改mNestedScrollingChildRef的值。

    本例采用反射修改值的方法解决这个问题:

        private final ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener = new MyOnGlobalLayoutListener();
    
        @Override
        public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
            super.onViewCreated(view, savedInstanceState);
            
            //注册globalLayoutListener,在layout完毕时,手动反射修改值
            view.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener);
            ...
        }
    
        @Override
        public void onDestroyView() {
            super.onDestroyView();
            View view = getView();
            if (view != null) {
                view.getViewTreeObserver().removeGlobalOnLayoutListener(onGlobalLayoutListener);
            }
        }
    
        private class MyOnGlobalLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener {
            @Override
            public void onGlobalLayout() {
                updateBehavior();
            }
    
            /**
             * BottomSheetBehavior#mNestedScrollingChildRef字段保存了嵌套滑动的子滑动View。
             * 所以这里根据当前展示的fragment手动设置一下BottomSheetBehavior#mNestedScrollingChildRef
             */
            private void updateBehavior() {
                View list = null;
                if (goodsFragment != null && goodsFragment.isVisible()) {
                    View view = goodsFragment.getView();
                    list = findScrollingChild(view);
    
                } else if (goodsTypeFragment != null && goodsTypeFragment.isVisible()) {
                    View view = goodsTypeFragment.getView();
                    list = findScrollingChild(view);
                }
    
                if (list != null) {
                    try {
                        Field field = BottomSheetBehavior.class.getDeclaredField("mNestedScrollingChildRef");
                        if (field != null) {
                            field.setAccessible(true);
                            field.set(behavior, new WeakReference<>(list));
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
    
            private View findScrollingChild(View view) {
                if (view instanceof NestedScrollingChild) {
                    return view;
                }
                if (view instanceof ViewGroup) {
                    ViewGroup group = (ViewGroup) view;
                    for (int i = 0, count = group.getChildCount(); i < count; i++) {
                        View scrollingChild = findScrollingChild(group.getChildAt(i));
                        if (scrollingChild != null) {
                            return scrollingChild;
                        }
                    }
                }
                return null;
            }
        }
    

    至此,GoodsBehaviorFragment的实现已经完成。

    使用BottomSheetDialogFragment实现下滑关闭的GoodsDialogFragment

    这种方式相对来说比较简单,直接继承自BottomSheetDialogFragment就可以。需要说明的是:BottomSheetDialogFragment如果要添加其他的Fragment,需要使用getChildFragmentManager()来添加,而不可以使用getActivity().getSupportFragmentManager()

    然而,看似快捷的实现,也暗藏大坑!

    坑7 item宽度问题

    直接看图:

    图10 item宽度问题

    可以看到,第一次展示item时,宽度变成了wrap_content,滑动列表,item复用时才展开到了parent的宽度。

    填充RecyclerView的Adapter是与GoodsBehaviorFragment共用的。inflate item的代码如下:

        @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
            View view = inflater.inflate(R.layout.list_item_goods_base, viewGroup, false);
            return new ViewHolder(view);
        }
    

    也并没有什么问题,但最终却出了问题。

    这个问题我还有找到根本原因,目前只是找到了一个解决方法,直接贴上:

    adapter中,手动设置item宽度:

        @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
            View view = inflater.inflate(R.layout.list_item_goods_base, viewGroup, false);
            ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
            params.width = viewGroup.getWidth() - viewGroup.getPaddingLeft() - viewGroup.getPaddingRight()
                    - params.leftMargin - params.rightMargin;
            if (params.width < 0) {
                params.width = ViewGroup.LayoutParams.MATCH_PARENT;
            }
            view.setLayoutParams(params);
            return new ViewHolder(view);
        }
    

    然后,在RecyclerView设置adapter时,做个延迟:

            vList.post(new Runnable() {
                @Override
                public void run() {
                    vList.setAdapter(adapter);
                }
            });
    

    两处修改双管齐下,可以解决这个问题。若有其他解决方法,还请不吝赐教。

    坑8 背景问题

    为了方便辨认,我们将自定义的带圆角的背景换一下颜色:

    图11 背景问题

    看两个红色箭头所示的地方,很明显,父布局有一个白色的背景。

    BottomSheetDialogFragment是由BottomSheetDialog实现的,在BottomSheetDialog的wrapInBottomSheet方法中:

    private View wrapInBottomSheet(int layoutResId, View view, ViewGroup.LayoutParams params) {
            final FrameLayout container = (FrameLayout) View.inflate(getContext(),
                    R.layout.design_bottom_sheet_dialog, null);
    }
    

    design_bottom_sheet_dialog.xml:

    <FrameLayout
        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:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">
    
        <android.support.design.widget.CoordinatorLayout
            android:id="@+id/coordinator"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true">
    
            <View
                android:id="@+id/touch_outside"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:importantForAccessibility="no"
                android:soundEffectsEnabled="false"
                tools:ignore="UnusedAttribute"/>
    
            <!-- contentview的父布局 -->
            <FrameLayout
                android:id="@+id/design_bottom_sheet"
                style="?attr/bottomSheetStyle"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal|top"
                app:layout_behavior="@string/bottom_sheet_behavior"/>
    
        </android.support.design.widget.CoordinatorLayout>
    </FrameLayout>
    

    style="?attr/bottomSheetStyle"中设置了背景色。

    手动去掉背景色:

        @Override
        public void onActivityCreated(Bundle savedInstanceState) {
            super.onActivityCreated(savedInstanceState);
            //去掉父布局的背景
            View view = getView();
            if (view != null) {
                View parent = (View) view.getParent();
                if (parent != null) {
                    parent.setBackgroundColor(Color.TRANSPARENT);
                }
            }
        }
    

    之所以在onActivityCreated中设置,是因为Dialog.setContentView是在super.onActivityCreated中执行的。

    至此,两种实现方式的坑差不多都填上了。完整实现代码,请转到
    Demo地址


    更正

    BottomSheetDialogFragment可以添加其他的Fragment,需要使用getChildFragmentManager()来添加,而不可以使用getActivity().getSupportFragmentManager()

    对之前的错误表示深深的歉意!

    相关文章

      网友评论

      本文标题:BottomSheetXXX实现下滑关闭菜单踩坑记

      本文链接:https://www.haomeiwen.com/subject/wbssgftx.html