BottomSheetXXX實現下滑關閉選單踩坑記
做開發時經常碰到底部選單的需求。通常情況下,不需要支援手勢滑動,只需要有滑動進入和滑動退出的效果即可。但有些時候,需要支援下滑關閉,這裡我們來踩踩下滑關閉的那些坑。
談到手勢下滑關閉,我們立即想到了 BottomSheetBehavior
、 BottomSheetDialog
、 BottomSheetDialogFragment
這三個類。它們本質上都是由 BottomSheetBehavior
實現,而 BottomSheetDialog
與 BottomSheetDialogFragment
是 Dialog
與 DialogFragment
的關係,所以我們僅以 BottomSheetBehavior
和 BottomSheetDialogFragment
兩個類來分別考慮如何實現。
下面開始探索之旅。以如下場景為例:
點選頁面按鈕彈出底部選單,首先展示商品種類的頁面,點選某一種類後切換到某一類商品頁面,點選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
這兩個屬性,然後順著當前應用的style Theme.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: GoodsTypeFragment
和 GoodsFragment
,分別是商品種類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中執行的。
至此,兩種實現方式的坑差不多都填上了。完整實現程式碼,請轉到
ofollow,noindex"> Demo地址更正
BottomSheetDialogFragment 可以新增 其他的Fragment,需要使用 getChildFragmentManager()
來新增,而不可以使用 getActivity().getSupportFragmentManager()
!
對之前的錯誤表示深深的歉意!