Android Material Design 之 BottomSheetBehavior

前面已经介绍了如何在地图上自定义Marker 和 Poi搜索 说实话录个视频真麻烦,还得转gif , 转就转吧图片还有大小限制 , 你说气人不 !

看过前两篇地图相关的博客,应该可以看出来, 屏幕底部有个展示数据的列表 ,可以跟随手指拖拽、滑动, 这样的效果在高德地图app中见过, 饿了么点餐的时候好像也有 , 其实这是**Google Material Design **的 BottomSheetBehavior

BottomSheetBehavior 啥意思? 近几年Google大力提倡Material Design , 这是个设计规范 , 里面重新定义了各种UI的规则 , 什么CoordinatorLayout、AppBarLayout、CollapsingToolbarLayout、RecyclerView、NestedScrollView....
官方文档的地址 https://material.io/develop/android/components/bottom-sheet-behavior/

**BottomSheetBehavior **是个底部动作条的意思 , 可以设置最小高度和最大高度 ,执行进入/退出动画,响应拖动/滑动手势等 滑动起来不要太流畅 , 光说文字看不出效果 , 先来张Google Map的实现效果吧

下面就开始来实现个类似的效果喽, 数据填充就用Poi搜索获取的数据 , 想要使用这个新的控件需要添加额外的依赖包

implementation 'com.android.support:design:27.1.0'

先看下布局文件吧

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/coordinatorLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <com.amap.api.maps.MapView
        android:id="@+id/mapView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true" />

    <android.support.v7.widget.CardView
        android:id="@+id/cardView"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:layout_margin="8dp"
        app:cardBackgroundColor="@color/white"
        app:cardCornerRadius="8dp"
        app:cardElevation="8dp"
        app:cardPreventCornerOverlap="false"
        app:cardUseCompatPadding="false">

        <android.support.v7.widget.AppCompatEditText
            android:id="@+id/search_view"
            style="@style/text_15_color_1_style"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="center_vertical"
            android:background="@null"
            android:cursorVisible="false"
            android:drawablePadding="8dp"
            android:drawableStart="@drawable/ic_search_gray_24dp"
            android:gravity="center_vertical"
            android:hint="搜索关键词"
            android:imeOptions="actionSearch"
            android:maxLines="1"
            android:paddingEnd="8dp"
            android:paddingStart="8dp"
            android:singleLine="true" />

        <ImageView
            android:id="@+id/close"
            style="@style/MSV_ImageButton"
            android:layout_gravity="center_vertical|end"
            android:src="@drawable/ic_action_navigation_close_gray24dp"
            android:visibility="invisible" />

    </android.support.v7.widget.CardView>

    <android.support.v4.widget.NestedScrollView
        android:id="@+id/nestedScrollView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:background="@drawable/white_solid_round_top_8"
        app:behavior_hideable="true"
        app:behavior_peekHeight="100dp"
        app:layout_behavior="@string/bottom_sheet_behavior">

      <android.support.v7.widget.RecyclerView
                android:id="@+id/recyclerView"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" /> 

    </android.support.v4.widget.NestedScrollView>

</android.support.design.widget.CoordinatorLayout>

想要使用BottomSheetBehavior , 其直接父View必须是CoordinatorLayout , 这是个功能强大的View, 可以协调子View的各种嵌套滑动 , 布局很简单,顶部搜索框 , 底部是NestedScrollView 可滑动控件 , CoordinatorLayout自带的 layout_behavior有两个,处理折叠滑动的 appbar_scrolling_view_behavior 和 处理BottomSheet的 bottom_sheet_behavior ; 本文使用的是后者 , 与此行为配合使用的属性还包括

app:behavior_hideable //设置为true表示底部弹出框可隐藏
app:behavior_peekHeight //窥视高度 , 就是BottomSheet折叠后的最小显示高度
app:behavior_skipCollapsed //如果app:behavior_hideable设置为true,并且behavior_skipCollapsed设置为true , 则它没有折叠状态。

重点就在这几个属性上面 ,控制BottomSheet的手势滑动 , 另外BottomSheet共有5种状态

STATE_COLLAPSED:折叠状态,就是peekHeight 设置的窥视高度
STATE_EXPANDED:完全展开
STATE_DRAGGING:拖动中
STATE_SETTLING:拖动/滑动手势后,将稳定到特定高度。如果用户操作导致底部页面隐藏,则这将是峰值高度,扩展高度或0。
STATE_HIDDEN:隐藏

现在运行代码看下效果

可以看到底部的列表一开始以最低高度出现 , 跟随手指滑动完全展开,然后向下滑恢复到折叠状态 , 最后隐藏 , 比起那些千篇一律的从头滑到尾的List有趣了很多 , 但是现在有了一个问题 , 列表完全展开的时候 , 输入框会遮挡列表, 这就尴尬了 ;

解决方案呢就是设置列表的高度 , 这时候就需要在代码中设置了 , 现在问题又来了, 怎么获取BottomSheet对象呢 ? findViewById 吗 ? 显然是行不通的 ;

因为Behavior是通过View来创建的,

BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(nestedScrollView);

上面的xml中我们在NestedScrollView中定义了layout_behavior , 那所以对应的View就是NestedScrollView ; 其实xml中设置的peekHeight 、hideable ... 这些属性也是可以通过Behavior对象在代码中设置的

        int peekHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100, getResources().getDisplayMetrics());
        bottomSheetBehavior.setPeekHeight(peekHeight);//设置最小高度
        bottomSheetBehavior.setHideable(true);//设置是否可隐藏
        bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);//设置当前为隐藏状态

这里定义BottomSheet最小高度peekHeight为100dp ; 那最大高度我应该设置为多少呢? 由于上面有个输入框 , 当底部列表完全展开的时候 ,应该让列表显示在输入框的下面 , 所以BottomSheet的最大高度应该是:

        //获取屏幕的高度
        int heightPixels = getResources().getDisplayMetrics().heightPixels;
        final CardView cardView = findViewById(R.id.cardView);
        cardView.post(new Runnable() {
            @Override
            public void run() {
                CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) cardView.getLayoutParams();

                //获取状态栏的高度
                int statusBarHeight = 0;
                int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
                if (resourceId > 0) {
                    statusBarHeight = getResources().getDimensionPixelSize(resourceId);
                }
                //输入框至屏幕顶部的高度
                int marginTop = cardView.getHeight() + lp.topMargin + lp.bottomMargin / 2 + statusBarHeight;
                //底部列表的最大高度
                int maxHeight = heightPixels - marginTop ;
            }
        });

咦? 为什么要减去状态栏的高度呢 ? 因为CoordinatorLayout 中我设置了 **android:fitsSystemWindows="true" , **将内容区域延伸到了状态栏 , 所以这里要用屏幕的总高度 减去 状态栏的高度

现在BottomSheet的最大高度已经获取到了 , 那应该在什么时候给它设置呢 ? 首先想到的就应该是Behavior的回调方法

bottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
            @Override
            public void onStateChanged(@NonNull View bottomSheet, int newState) {
                if (newState != BottomSheetBehavior.STATE_DRAGGING) {
                    ViewGroup.LayoutParams layoutParams = bottomSheet.getLayoutParams();
                    if (bottomSheet.getHeight() > maxHeight ) {
                        layoutParams.height = maxHeight ;
                        bottomSheet.setLayoutParams(layoutParams);
                    }
                }
            }

            @Override
            public void onSlide(@NonNull View bottomSheet, float slideOffset) {

                }               
            }
        });

前面有说道Behavior有5中状态 , 就是在onStateChanged () 中进行回调的 , 第一个参数 View bottomSheet 就是当前BottomSheet所持有的View , 我们要动态设置高度的对象就是它

这里是在非拖拽的状态下进行判断 ,如果 bottomSheet.getHeight() > maxHeight 将bottomSheet的最大高度设置为maxHeight ,这样就解决了输入框覆盖列表的问题了

写到这里, 总觉得还欠缺点什么 , 上面的Google map 图片中 右下角的定位按钮 是如何移动的 ? 而且还是跟随Behavior一起滑动 , 好像地图也是在跟随滑动

Behavior的回调方法我们才用了一个而已 , 不是还有个onSlide () 了吗 , 大胆猜一下第二个参数slideOffset 代表啥意思 ?

从命名上来看就知道肯定是滑动时候的高度偏移量喽 , 没错 , 就是它 , 但是这里有个坑 , 既然是高度的偏移量 , 那这个高度是多少呢 ? 如果你认为是上面设置的最大高度的话, 那么恭喜你, 并不是 ! 准确的来说 , 这里应该分两种情况来分析 :

  1. 当Behavior在折叠状态到隐藏状态之间滑动(向上、向下)的时候 , slideOffset 对应的最大高度是peekHeight而并非maxHeight ;
    此时slideOffset取值范围是[-1,0];
  2. 当Behavior在折叠状态到展开状态之间滑动(向上、向下)的时候, slideOffset对应的最大高度才是maxHeight ;

此时slideOffset取值范围是[0,1];

你妹的, 这坑真不小 , 反复计算了好多次 ,才发现这个问题 , 奶奶的 , 你说气人不 !

现在xml中添加两个button , 以便于跟随Behavior一起滑动

<LinearLayout
        android:id="@+id/fab_container"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_marginBottom="120dp"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:background="@android:color/transparent"
        android:gravity="center_horizontal"
        android:orientation="vertical"
        android:paddingBottom="8dp"
        android:translationY="120dp">

        <android.support.design.widget.FloatingActionButton
            android:id="@+id/fab_my_location"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_my_location_blue_24dp"
            app:elevation="0dp"
            app:rippleColor="@color/translate_white" />

        <android.support.design.widget.FloatingActionButton
            android:id="@+id/fab_scrolling"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:src="@drawable/ic_navigation_blue_24dp"
            app:elevation="0dp"
            app:rippleColor="@color/translate_white" />

    </LinearLayout>

在回调方法onSlide() 中进行设置 ; 需要用到View的setTranslationY () ; Y轴方向上的平移

        @Override
        public void onSlide(@NonNull View bottomSheet, float slideOffset) {
                float distance;
                if (slideOffset > 0) {//在peekHeight位置以上 滑动(向上、向下) slideOffset bottomSheet.getHeight() 是展开后的高度的比例
                    distance = bottomSheet.getHeight() * slideOffset;
                } else {//在peekHeight位置以下 滑动(向上、向下)  slideOffset 是PeekHeight的高度的比例
                    distance = bottomSheetBehavior.getPeekHeight() * slideOffset;
                }
                if (distance < 0) {
                    fabContainer.setTranslationY(-distance);
                    mapView.setTranslationY(0);
                } else {
                    if (distance <= peekHeight) {
                        fabContainer.setTranslationY(-distance);
                        mapView.setTranslationY(-distance);
                    }
                }
                Log.e(TAG, String.format("slideOffset -->>> %s bottomSheet.getHeight() -->>> %s heightPixels -->>> %s", slideOffset, bottomSheet.getHeight(), heightPixels));
            }

distance 就是当前偏移的高度了 ,distance > 0 是向上滑动 , 此时要取相反数去设置Y轴位移 ,反之亦然 .

当distance > 0 的时候 , 为了不让button一直跟随Behavior滑动 , 这里加了个限制条件, distance <= peekHeight 的时候才位移

这样就实现了Google Map的效果了

到这里就结束了 ,下次讲appbar_scrolling_view_behavior , 后续会介绍更多Material Design的组件
代码已在GitHub托管https://github.com/good-good-study/WeChartApplication

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349