做開發(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),而BottomSheetDialog與BottomSheetDialogFragment是Dialog與DialogFragment的關(guān)系,所以我們僅以BottomSheetBehavior和BottomSheetDialogFragment兩個(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看下效果圖:

發(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:GoodsTypeFragment和GoodsFragment,分別是商品種類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):


顯然,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ā)生了如下崩潰:

崩潰處代碼如下:
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),如下:

再次下滑,才會(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)象如下:

上面提到過,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è)置完畢,再來看一下整體效果:

效果看起來不錯(cuò),也可以下滑關(guān)閉。但到此就完事了嗎?看一下嵌套滑動(dòng)時(shí)的下滑關(guān)閉功能
坑6 展示某類商品時(shí),嵌套滑動(dòng)失效
展示商品種類列表時(shí):

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

此時(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寬度問題
直接看圖:

可以看到,第一次展示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),我們將自定義的帶圓角的背景換一下顏色:

看兩個(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ò)誤表示深深的歉意!