BottomSheetXXX實(shí)現(xiàn)下滑關(guān)閉菜單踩坑記

做開發(fā)時(shí)經(jīng)常碰到底部菜單的需求。通常情況下,不需要支持手勢滑動(dòng),只需要有滑動(dòng)進(jìn)入和滑動(dòng)退出的效果即可。但有些時(shí)候,需要支持下滑關(guān)閉,這里我們來踩踩下滑關(guān)閉的那些坑。

談到手勢下滑關(guān)閉,我們立即想到了BottomSheetBehavior、BottomSheetDialog、BottomSheetDialogFragment這三個(gè)類。它們本質(zhì)上都是由BottomSheetBehavior實(shí)現(xiàn),而BottomSheetDialogBottomSheetDialogFragmentDialogDialogFragment的關(guān)系,所以我們僅以BottomSheetBehaviorBottomSheetDialogFragment兩個(gè)類來分別考慮如何實(shí)現(xiàn)。

下面開始探索之旅。以如下場景為例:

點(diǎn)擊頁面按鈕彈出底部菜單,首先展示商品種類的頁面,點(diǎn)擊某一種類后切換到某一類商品頁面,點(diǎn)擊back鍵或者返回按鈕返回到商品種類頁面。
兩個(gè)頁面各包含一個(gè)列表,底部菜單支持嵌套滑動(dòng),下拉關(guān)閉。

主頁面布局如下:

<?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 按鈕在底部菜單之上

非常簡單的布局就碰到了一個(gè)坑,我們給FrameLayout設(shè)置一個(gè)藍(lán)色背景android:background="#09c看下效果圖:

圖1 第一個(gè)坑

發(fā)生了什么情況?FrameLayout不應(yīng)該在最上層嗎,為什么兩個(gè)按鈕沒有被覆蓋?

沒有被覆蓋的話,推測應(yīng)該是按鈕被設(shè)置了translationZ或者elevation這兩個(gè)屬性,然后順著當(dāng)前應(yīng)用的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>

繼續(xù)找,在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中找到了目標(biāo):

<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可知,按鈕按下狀態(tài),有最大Z軸位置6dp,所以可以為FrameLayout添加如下屬性:

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

使用BottomSheetBehavior實(shí)現(xiàn)下滑關(guān)閉的GoodsBehaviorFragment

接下來研究如何通過BottomSheetBehavior實(shí)現(xiàn)下滑關(guān)閉。首先看一下BottomSheetBehavior的幾種狀態(tài):

  • STATE_DRAGGING:拖動(dòng)狀態(tài)
  • STATE_SETTLING:松開手指后,自由滑動(dòng)狀態(tài)
  • STATE_EXPANDED:完全展開狀態(tài)
  • STATE_COLLAPSED:折疊狀態(tài),或者稱為半展開狀態(tài)
  • STATE_HIDDEN:隱藏狀態(tài)

本例中,GoodsBehaviorFragment是用來展示商品信息的底部菜單,設(shè)置了BottomSheetBehavior,實(shí)現(xiàn)下滑關(guān)閉功能。該fragment包含了兩個(gè)子fragment:GoodsTypeFragmentGoodsFragment,分別是商品種類fragment和某一種類的商品fragment。點(diǎn)擊GoodsTypeFragment的一項(xiàng),進(jìn)入該種類的列表。兩個(gè)子fragment各包含一個(gè)RecyclerView列表,所以還需要保證能夠嵌套滑動(dòng)(下滑關(guān)閉功能和列表的嵌套滑動(dòng))。

坑2 菜單首次彈出顯示不全

BottomSheetBehavior默認(rèn)是STATE_COLLAPSED,初次接觸,總會(huì)被這個(gè)狀態(tài)蹂躪一番。首先來看看這到底是個(gè)什么樣的狀態(tài):

圖2 STATE_COLLAPSED
圖3 STATE_EXPANDED

顯然,STATE_COLLAPSED不是我們想要的狀態(tài),在實(shí)現(xiàn)類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);
            }
        });
        ...
    }

這樣在每次彈出時(shí),便進(jìn)入了STATE_EXPANDED狀態(tài)。

坑3 隱藏菜單時(shí)崩潰

然而,當(dāng)調(diào)用behavior.setState(BottomSheetBehavior.STATE_HIDDEN);來隱藏菜單時(shí),發(fā)生了如下崩潰:

圖4 隱藏菜單時(shí)的崩潰信息

崩潰處代碼如下:

    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) {
           //這是想要進(jìn)入的隱藏狀態(tài)
            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);
        }
    }

可見,想要進(jìn)入state == STATE_HIDDEN這個(gè)分支,還需要mHideable==true才可以,所以設(shè)置如下方法:

behavior.setHideable(true);

坑4 下滑仍會(huì)進(jìn)入STATE_COLLAPSED狀態(tài)

如上設(shè)置完畢,在菜單區(qū)域下滑,發(fā)現(xiàn)首先會(huì)進(jìn)入STATE_COLLAPSED狀態(tài),如下:

圖5 下滑進(jìn)入STATE_COLLAPSED狀態(tài)

再次下滑,才會(huì)隱藏。BottomSheetBehavior有如下方法判斷是否該隱藏:

    boolean shouldHide(View child, float yvel) {
        if (mSkipCollapsed) {
            return true;
        }
        if (child.getTop() < mMaxOffset) {//這里進(jìn)入到了折疊狀態(tài)
            // 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,需要設(shè)置如下方法:

behavior.setSkipCollapsed(true);

表示在隱藏時(shí),跳過折疊狀態(tài),直接進(jìn)入隱藏狀態(tài)。

坑5 菜單彈出時(shí),不是從底部彈出的

現(xiàn)象如下:

圖6 菜單不是從底部彈出

上面提到過,BottomSheetBehavior的初始狀態(tài)是折疊態(tài),折疊態(tài)時(shí),菜單的高度可以通過setPeekHeight方法設(shè)置。
雖然我們不需要折疊狀態(tài),但因?yàn)檎郫B狀態(tài)是默認(rèn)態(tài),所以即便我們一開始就設(shè)置了展開狀態(tài),實(shí)際上底部菜單是從折疊狀態(tài)的高度(而非隱藏狀態(tài)的0)過渡到展開狀態(tài)的高度。
所以為了達(dá)到我們想要的效果(菜單高度從0過渡到展開狀態(tài)的高度),需要設(shè)置如下代碼:

behavior.setPeekHeight(0);

設(shè)置完畢,再來看一下整體效果:

圖7 彈出、隱藏效果展示

效果看起來不錯(cuò),也可以下滑關(guān)閉。但到此就完事了嗎?看一下嵌套滑動(dòng)時(shí)的下滑關(guān)閉功能

坑6 展示某類商品時(shí),嵌套滑動(dòng)失效

展示商品種類列表時(shí):

圖8 展示商品種類列表時(shí)可以嵌套滑動(dòng)

可以嵌套滑動(dòng),沒問題。再看展示某類商品列表時(shí):

圖9 展示某類商品列表時(shí)不可以嵌套滑動(dòng)

此時(shí)不可以嵌套滑動(dòng)了。

繼續(xù)翻看BottomSheetBehavior源碼,在onLayoutChild方法中有這么一句:

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

mNestedScrollingChildRef用于保存嵌套滑動(dòng)的子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;
    }

該方法遞歸查找,將找到的第一個(gè)RecyclerView返回。
那么問題來了,在展示某類商品的列表GoodsFragment時(shí),商品種類列表GoodsTypeFragment并沒有被remove掉,也就是說同時(shí)存在兩個(gè)RecyclerView,而從findScrollingChild的查找順序看,總是會(huì)返回GoodsTypeFragment的列表,這才導(dǎo)致展示GoodsFragment時(shí),不能嵌套滑動(dòng)。

找到了原因,這個(gè)問題就不難解決了。一種方式是每次只添加一個(gè)fragment,自然不會(huì)存在多個(gè)RecyclerView的情況。但很多時(shí)候我們是需要兩個(gè)fragment共存的。這時(shí)可以通過反射來修改mNestedScrollingChildRef的值。

本例采用反射修改值的方法解決這個(gè)問題:

    private final ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener = new MyOnGlobalLayoutListener();

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        
        //注冊globalLayoutListener,在layout完畢時(shí),手動(dòng)反射修改值
        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字段保存了嵌套滑動(dòng)的子滑動(dòng)View。
         * 所以這里根據(jù)當(dāng)前展示的fragment手動(dòng)設(shè)置一下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的實(shí)現(xiàn)已經(jīng)完成。

小提示:使用反射時(shí),注意添加混淆規(guī)則!

使用BottomSheetDialogFragment實(shí)現(xiàn)下滑關(guān)閉的GoodsDialogFragment

這種方式相對來說比較簡單,直接繼承自BottomSheetDialogFragment就可以。需要說明的是:BottomSheetDialogFragment如果要添加其他的Fragment,需要使用getChildFragmentManager()來添加,而不可以使用getActivity().getSupportFragmentManager()

然而,看似快捷的實(shí)現(xiàn),也暗藏大坑!

坑7 item寬度問題

直接看圖:

圖10 item寬度問題

可以看到,第一次展示item時(shí),寬度變成了wrap_content,滑動(dòng)列表,item復(fù)用時(shí)才展開到了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);
    }

也并沒有什么問題,但最終卻出了問題。

這個(gè)問題我還有找到根本原因,目前只是找到了一個(gè)解決方法,直接貼上:

adapter中,手動(dòng)設(shè)置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設(shè)置adapter時(shí),做個(gè)延遲:

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

兩處修改雙管齊下,可以解決這個(gè)問題。若有其他解決方法,還請不吝賜教。

坑8 背景問題

為了方便辨認(rèn),我們將自定義的帶圓角的背景換一下顏色:

圖11 背景問題

看兩個(gè)紅色箭頭所示的地方,很明顯,父布局有一個(gè)白色的背景。

BottomSheetDialogFragment是由BottomSheetDialog實(shí)現(xiàn)的,在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"中設(shè)置了背景色。

手動(dòng)去掉背景色:

    @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中設(shè)置,是因?yàn)镈ialog.setContentView是在super.onActivityCreated中執(zhí)行的。

至此,兩種實(shí)現(xiàn)方式的坑差不多都填上了。完整實(shí)現(xiàn)代碼,請轉(zhuǎn)到
Demo地址


更正

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

對之前的錯(cuò)誤表示深深的歉意!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 用兩張圖告訴你,為什么你的 App 會(huì)卡頓? - Android - 掘金 Cover 有什么料? 從這篇文章中你...
    hw1212閱讀 14,118評論 2 59
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 179,347評論 25 708
  • 1、通過CocoaPods安裝項(xiàng)目名稱項(xiàng)目信息 AFNetworking網(wǎng)絡(luò)請求組件 FMDB本地?cái)?shù)據(jù)庫組件 SD...
    陽明AI閱讀 16,235評論 3 119
  • 小孩子很可愛,也很單純。 我也喜歡跟孩子們待在一起,跟他們待在一起不用想太多,表面上看起來是什么就是什么。 最近我...
    泉水叮咚520閱讀 327評論 0 0
  • 大西洋北海地圖
    Naya閱讀 114評論 0 0

友情鏈接更多精彩內(nèi)容