Android开发经验谈android

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

2018-09-13  本文已影响270人  sollian

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

谈到手势下滑关闭,我们立即想到了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的几种状态:

本例中,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()

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

上一篇下一篇

猜你喜欢

热点阅读