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
看下效果图:
发生了什么情况?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:GoodsTypeFragment
和GoodsFragment
,分别是商品种类fragment和某一种类的商品fragment。点击GoodsTypeFragment
的一项,进入该种类的列表。两个子fragment各包含一个RecyclerView列表,所以还需要保证能够嵌套滑动(下滑关闭功能和列表的嵌套滑动)。
坑2 菜单首次弹出显示不全
BottomSheetBehavior
默认是STATE_COLLAPSED
,初次接触,总会被这个状态蹂躏一番。首先来看看这到底是个什么样的状态:
显然,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);
来隐藏菜单时,发生了如下崩溃:
崩溃处代码如下:
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
状态,如下:
再次下滑,才会隐藏。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()
!
对之前的错误表示深深的歉意!