美文网首页Android开发经验谈Android开发
Android Notes|BottomNavigationVi

Android Notes|BottomNavigationVi

作者: 孙江_2fe4 | 来源:发表于2020-09-10 14:01 被阅读0次

    作者:静心Study
    链接:https://juejin.im/post/6867895624025997320

    鸡老大说:大丈夫岂能久居人下。

    前言

    好容易解决个问题,感觉记录一波。

    当日事当日毕,践行鸡老大,点滴积累,万一某天优秀了呢?

    以前大部分项目底部导航栏关于图片部分的实现,要么两套图 selector 切换,要么通过着色器 tint 进行渲染,总之最后呈现的效果便是在点击时两张图静态切换,说 Low 吧,也还凑合,但是总是没那么高大上。

    项目重构时,韩总说了,之前的方式呈现的效果太 Low 了,这次重构要求底部要动。(内心默默来句,你咋不上天。)

    先来看个两者间对比效果吧~

    效果对比

    • 原有两张静态图切换:
    • 小动画浪起来:

    前期介绍

    针对目前使用的 BottomNavigationView 以及 Lottie 简单记录下,以便日后遗忘直接查看。

    1. BottomNavigationView 简述

    简单记录,后续想到随时补充。

    一般我用于底部导航栏,最多支持 5 个 item,源码有写,如下:

        @RestrictTo(LIBRARY_GROUP)
        public final class BottomNavigationMenu extends MenuBuilder {
          public static final int MAX_ITEM_COUNT = 5;
    
          public BottomNavigationMenu(Context context) {
            super(context);
          }
    
          @NonNull
          @Override
          public SubMenu addSubMenu(int group, int id, int categoryOrder, CharSequence title) {
            throw new UnsupportedOperationException("BottomNavigationView does not support submenus");
          }
    
          @Override
          protected MenuItem addInternal(int group, int id, int categoryOrder, CharSequence title) {
            // 超过 5 个则抛出异常
            if (size() + 1 > MAX_ITEM_COUNT) {
              throw new IllegalArgumentException(
                  "Maximum number of items supported by BottomNavigationView is "
                      + MAX_ITEM_COUNT
                      + ". Limit can be checked with BottomNavigationView#getMaxItemCount()");
            }
            // ...
            return item;
          }
        }
    复制代码
    

    对于基本的 MenuItem Icon 选中/默认切换,一般配合 menu 来食用,例如。

        <menu xmlns:android="http://schemas.android.com/apk/res/android">
          <item
              android:id="@+id/page_1"
              android:icon="@drawable/icon_1"
              android:title="@string/text_label_1"/>
          <!-- ... -->
        </menu>
    复制代码
    

    相对比较简单的方式,便是提供一套默认的 Icon,然后根据选中进行 tint 着色,当然,也可以通过 selector 选择器去设置对应选中以及未选中的 Icon,根据个人喜欢以及项目自行选择。

    设置字体颜色,尤其默认以及选中,同样可以通过 selector 选择器进行对应设置。

    而关于选中状态切换时,对应标题字体大小发生改变以及导航栏高度,都可以通过在 dimens 定义如下解决:

        <!-- 处理 BottomNavigationView 点击放大 -->
        <dimen name="design_bottom_navigation_active_text_size">@dimen/sp_12</dimen>
        <dimen name="design_bottom_navigation_text_size">@dimen/sp_12</dimen>
        <!-- 设置导航栏高度 -->
        <dimen name="design_bottom_navigation_height">84dp</dimen>
    复制代码
    

    对于设置角标,也就是右上角小圆点或者对应的数字,可通过获取 Badge 进行对应设置,这里简单复制官方例子:

        var badge = bottomNavigation.getOrCreateBadge(menuItemId)
        badge.isVisible = true
        // An icon only badge will be displayed unless a number is set:
        badge.number = 99
    复制代码
    

    基本常用属性:

    属性/名称 作用 默认值
    android:background 背景色
    app:itemBackground item 背景色,@null 代表禁用水波纹效果
    app:menu 底部对应显示的布局文件
    app:itemIconSize item icon 大小 24dp
    app:itemIconTint item icon 着色器 ?attr/colorOnSurface at 60%
    app:itemTextColor item text 颜色 ?attr/colorOnSurface at 60%
    app:labelVisibilityMode item 标签显示模式 LABEL_VISIBILITY_AUTO(auto)

    需要单独说明的属性:

    • app:labelVisibilityMode: item 标签显示模式
      • auto: item 少于等于 3 个时,标题处于显示状态;大于等于 4 个,选中才显示标题;
      • selected: 选中才显示标题;
      • labeled: 标题一直显示;
      • unlabeled: 只显示 icon,不显示标题。

    2. Lottie

    对于这个东东,不知道说啥。忽略吧。

    想起来都是累,韩总让我自己折腾 Lottie json 文件。哭唧唧

    安利一个在线编辑 Lottie json 文件的地址:

    实战部分

    Step 1:导入提供的 Lottie Json 文件

    新建 assets 目录,这里我做了 Android 10 深色兼容,所以需要提供深色(暗黑)模式下 Lottie 文件。

    老渣男,给我的素材用不了,害我借用别人家的 App Lottie 素材。

    Step 2:定义 Lottie 枚举类并封装基础数据:

        enum class LottieAnimation(val value: String) { 
            // 截取「喜马拉雅」App Lottie 素材
    
            HOME("lottie/bottom_tab_home_page_btn.json"),
            SUBSCRIBE("lottie/bottom_tab_my_listen_btn.json"),
            DISCOVERY("lottie/bottom_tab_finding_btn.json"),
            ACCOUNT("lottie/bottom_tab_mine_btn.json"),
    
            HOME_NIGHT("lottie-night/bottom_tab_home_page_btn.json"),
            SUBSCRIBE_NIGHT("lottie-night/bottom_tab_my_listen_btn.json"),
            DISCOVERY_NIGHT("lottie-night/bottom_tab_finding_btn.json"),
            ACCOUNT_NIGHT("lottie-night/bottom_tab_mine_btn.json")
        }
    复制代码
    

    封装个 BasicData,存放 App 内置的一些基本数据,这里主要针对 Lottie 文件:

        val mNavigationAnimationList = arrayListOf(
            LottieAnimation.HOME,
            LottieAnimation.SUBSCRIBE,
            LottieAnimation.DISCOVERY,
            LottieAnimation.ACCOUNT
        )
    
        val mNavigationAnimationNightList = arrayListOf(
            LottieAnimation.HOME_NIGHT,
            LottieAnimation.SUBSCRIBE_NIGHT,
            LottieAnimation.DISCOVERY_NIGHT,
            LottieAnimation.ACCOUNT_NIGHT
        )
    复制代码
    

    Step 3:导入对应依赖,新增 Lottie Utils

        api 'com.google.android.material:material:1.2.0'
        api 'com.airbnb.android:lottie:3.4.1'
    复制代码
    

    工具类方法:

        /**
         * 获取 Lottie Drawable
         */
        fun getLottieDrawable(
            animation: LottieAnimation,
            bottomNavigationView: BottomNavigationView
        ): LottieDrawable {
            return LottieDrawable().apply {
                val result = LottieCompositionFactory.fromAssetSync(
                    bottomNavigationView.context.applicationContext, animation.value
                )
                callback = bottomNavigationView
                composition = result.value
            }
        }
    
        /**
         * 获取不同模式下 Lottie json 文件
         */
        fun getLottieAnimationList(context: Context): ArrayList<LottieAnimation> {
            return if (isDarkTheme(context)) {
                mNavigationAnimationNightList
            } else {
                mNavigationAnimationList
            }
        }
    复制代码
    

    判断是否深色模式我单独提取了一个工具类,Lottie-android 中也有对深色模式的兼容方法:

        /**
         * 验证当前是否为深色模式
         */
        fun isDarkTheme(context: Context): Boolean {
            val flag = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
            return flag == Configuration.UI_MODE_NIGHT_YES
        }
    复制代码
    

    Step 4:设置布局

    先添加个 tab 字体选中和非选中的字体颜色 selecor:

        <?xml version="1.0" encoding="utf-8"?>
        <selector xmlns:android="http://schemas.android.com/apk/res/android">
            <item android:color="@color/colorMain" android:state_checked="true" />
            <item android:color="@color/colorTitleText" android:state_checked="false" />
        </selector>
    复制代码
    

    整一波布局文件:

        <?xml version="1.0" encoding="utf-8"?>
        <androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/colorBackground"
            tools:context=".module.home.activity.HomeActivity">
    
            <com.google.android.material.bottomnavigation.BottomNavigationView
                android:id="@+id/nav_bottom_bar"
                android:layout_width="@dimen/dp_0"
                android:layout_height="wrap_content"
                android:background="@color/colorBackground"
                app:itemIconSize="@dimen/dp_30"
                app:itemTextColor="@color/selector_menu_state_navigation"
                app:labelVisibilityMode="labeled"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />
    
            <FrameLayout
                android:layout_width="@dimen/dp_0"
                android:layout_height="@dimen/dp_0"
                app:layout_constraintBottom_toTopOf="@id/nav_bottom_bar"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
    
        </androidx.constraintlayout.widget.ConstraintLayout>
    复制代码
    

    Step 5:初始化 BottomNavigationView 以及 Menu

        private fun initBottomNavigationView() {
            nav_bottom_bar.menu.apply {
                for (i in 0 until mNavigationTitleList.size) {
                    add(Menu.NONE, i, Menu.NONE, mNavigationTitleList[i])
                }
                setLottieDrawable(getLottieAnimationList(mSelfActivity))
            }
            initEvent()
        }
    
        private fun initEvent() {
            nav_bottom_bar.setOnNavigationItemSelectedListener(this)
            nav_bottom_bar.setOnNavigationItemReselectedListener(this)
            // 默认选中第一个
            nav_bottom_bar.selectedItemId = 0
            // 处理长按 MenuItem 提示 TooltipText
            nav_bottom_bar.menu.forEach {
                val menuItemView = mSelfActivity.findViewById(it.itemId) as BottomNavigationItemView
                menuItemView.setOnLongClickListener {
                    true
                }
            }
        }
    
        private fun Menu.setLottieDrawable(lottieAnimationList: ArrayList<LottieAnimation>) {
            for (i in 0 until mNavigationTitleList.size) {
                findItem(i)?.icon =
                    getLottieDrawable(lottieAnimationList[i], nav_bottom_bar)
            }
        }
    
        override fun onNavigationItemSelected(item: MenuItem): Boolean {
            handleNavigationItem(item)
            return true
        }
    
        override fun onNavigationItemReselected(item: MenuItem) {
            handleNavigationItem(item)
        }
    
        private fun handleNavigationItem(item: MenuItem) {
            handlePlayLottieAnimation(item)
            mPreClickPosition = item.itemId
        }
    
        private fun handlePlayLottieAnimation(item: MenuItem) {
            val currentIcon = item.icon as? LottieDrawable
            currentIcon?.apply {
                playAnimation()
            }
            // 处理 tab 切换,icon 对应调整
            if (item.itemId != mPreClickPosition) {
                nav_bottom_bar.menu.findItem(mPreClickPosition).icon =
                    getLottieDrawable(
                        getLottieAnimationList(mSelfActivity)[mPreClickPosition],
                        nav_bottom_bar
                    )
            }
        }
    复制代码
    

    问题汇总

    鸡老大说:

    • 遇到问题是好事儿,多总结,多积累,掌握一个循循渐进的过程。

    1、BottomNavigationView 切换对应的 Lottie 不改变,怎么玩?

    这个问题是我从一开始就陷入了固有思维循环中。

    下面是我陷入误区的思路:

    • 我想着因为是通过 playAnimation 开始执行动画从而过渡到最后的颜色,那么对应的 endAnimation 应该是直接能回到初始状态。那么我直接缓存上一此点击 MenuItem 然后修改状态不就好了嘛。
    • tint 着色器修改?

    整整折腾了好久,折腾到韩总说,不行咱就放弃吧。

    想想鸡老大,怎能轻易放弃?

    昨天突然想到,为什么我不重新给设置一次 Drawable 呢?反正初始的 Drawable 就是灰色,当然也是未选中的状态,随后赶紧实战测试了一波,附上关键代码:

        override fun onNavigationItemReselected(item: MenuItem) {
            handleNavigationItem(item)
        }
    
        private fun handleNavigationItem(item: MenuItem) {
            handlePlayLottieAnimation(item)
            mPreClickPosition = item.itemId
        }
    
        private fun handlePlayLottieAnimation(item: MenuItem) {
            val currentIcon = item.icon as? LottieDrawable
            currentIcon?.apply {
                playAnimation()
            }
            // 这里判断如果当前点击的和上一次点击索引不同,则将上一次点击索引位置的 MenuItem Icon 替换
            if (item.itemId != mPreClickPosition) {
                // 获取到上一个 MenuItem 并修改对应的 icon drawable
                nav_bottom_bar.menu.findItem(mPreClickPosition).icon =
                    getLottieDrawable(
                        getLottieAnimationList(mSelfActivity)[mPreClickPosition],
                        nav_bottom_bar
                    )
            }
        }
    复制代码
    

    具体代码参考文章实战部分。

    小教训(心得):

    • 真的是有时候不得不换种思维方式,首要的便是实现,随后才是优化。基本雏形都没有,何谈优化?

    • 身为猿猿,面对实际开发中遇到的问题,一定要采取多方案,首要保证内容、结果的输出,其次才是合理的循循渐进的优化。

    2、BottomNavigationView Item 长按提示怎么搞掉?

    先来看个效果图:

    比较尴尬的是,用了很久了,头一次某天闲来无事长按发现的这个东西,当时还好奇,没事弹个 Toast 干啥玩意?怀揣着满满的自信,去找源码关于我理想中认为的这个 Toast,没找到。

    来,正好一起翻翻源码,看看能否从源码的角度去思考并解决这个问题。

    进行一波简要分析:

    从上图以及实际编码过程中,我们可以得知,所谓的底部 item,其实只是一个数量不能超过 5 个的 Menu,而实际触发这个提示肯定是 ItemView,那么一起来看下针对 itemView 它做了什么操作?

    在初始化 ItemView 时,最后有这么一段设置,一起来看下:

      @Override
      public void initialize(@NonNull MenuItemImpl itemData, int menuType) {
        // ... 
        CharSequence tooltipText = !TextUtils.isEmpty(itemData.getTooltipText())
            ? itemData.getTooltipText()
            : itemData.getTitle();
        TooltipCompat.setTooltipText(this, tooltipText);
        setVisibility(itemData.isVisible() ? View.VISIBLE : View.GONE);
      }
    复制代码
    

    tooltipText,工具提示文本?有点茫然,似乎有点意思。这里有个验证,如果 tooltipText 为空则使用 title 值,反正使用自身值,最后将最终的判断结果进行 setTooltipText,我们一起来看看这个 set 里面干了什么?

    /**
     * Helper class used to emulate the behavior of {@link View#setTooltipText(CharSequence)} prior
     * to API level 26.
     *
     */
    public class TooltipCompat  {
        /**
         * Sets the tooltip text for the view.
         * <p> Prior to API 26 this method sets or clears (when tooltip is null) the view's
         * OnLongClickListener and OnHoverListener. A toast-like subpanel will be created on long click
         * or mouse hover.
         *
         * @param view the view to set the tooltip text on
         * @param tooltipText the tooltip text
         */
        public static void setTooltipText(@NonNull View view, @Nullable CharSequence tooltipText) {
            if (Build.VERSION.SDK_INT >= 26) {
                view.setTooltipText(tooltipText);
            } else {
                TooltipCompatHandler.setTooltipText(view, tooltipText);
            }
        }
    
        private TooltipCompat() {}
    }
    复制代码
    

    注释写的倒是蛮清楚的,在 Api 26 添加的一个工具提示文本,主要用于长按或者鼠标悬停的一个提示,类似 Toast。

    继续往下看,set 到底干了啥?

        /**
         * Sets the tooltip text which will be displayed in a small popup next to the view.
         * // ...
         * // 重点是下面参数描述,如果不需要设置 null
         * @param tooltipText the tooltip text, or null if no tooltip is required
         * @see #getTooltipText()
         * @attr ref android.R.styleable#View_tooltipText
         */
        public void setTooltipText(@Nullable CharSequence tooltipText) {
            if (TextUtils.isEmpty(tooltipText)) {
                setFlags(0, TOOLTIP);
                // hide 隐藏?鸡老大万岁
                hideTooltip();
                mTooltipInfo = null;
            } else {
                // ... 
            }
        }
    复制代码
    

    可以很明确的看到,如果不想显示这个所谓的 tooltipText 只需要将其设置为空即可。看到这里大概有个思路了,但是还是好奇弹出的这个东西是啥?继续往下找。

        @UnsupportedAppUsage
        void hideTooltip() {
            // ...
            if (mTooltipInfo.mTooltipPopup == null) {
                return;
            }
            mTooltipInfo.mTooltipPopup.hide();
            mTooltipInfo.mTooltipPopup = null;
            // ...
        }
    复制代码
    

    ummm,原来是个 PopupWindow。

    好吧,回归正题,取消 BottomNavigationView 长按时的 tooltipText 提示。

    循环遍历 Menu 并将 tooltipText 设置为 null。

        nav_bottom_bar.menu.forEach {
            TooltipCompat.setTooltipText(mSelfActivity.findViewById(it.itemId),null) 
        }
    复制代码
    

    来看下效果图:

    ummm。不对呀。首次进来两个 Tab 长按符合预期,后续呢?

    ummm,或者,我直接断了丫的念想?直接拦截长按事件一波?

        nav_bottom_bar.menu.forEach {
            val menuItemView = mSelfActivity.findViewById(it.itemId) as BottomNavigationItemView
            menuItemView.setOnLongClickListener {
                true
            }
        }
    
    

    运行一波看看?

    ummm,好扎心。

    3、选中时,想来个动画,怎么搞?(2020/09/03)

    昨天大半夜,韩总发消息,部分内容如下:

    ummm,我这个扎心的啊。都要准备入眠来着。

    好吧,简单分析波。

    先附上关于 BottomNavigationView 的 item 部分代码,其实是 Menu Item:

        <merge xmlns:android="http://schemas.android.com/apk/res/android">
          <ImageView
              android:id="@+id/icon"
              android:layout_width="24dp"
              android:layout_height="24dp"
              android:layout_marginTop="@dimen/design_bottom_navigation_margin"
              android:layout_marginBottom="@dimen/design_bottom_navigation_margin"
              android:layout_gravity="center_horizontal"
              android:contentDescription="@null"
              android:duplicateParentState="true"/>
          <com.google.android.material.internal.BaselineLayout
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:layout_gravity="bottom|center_horizontal"
              android:paddingBottom="10dp"
              android:clipChildren="false"
              android:clipToPadding="false"
              android:duplicateParentState="true">
            <TextView
                android:id="@+id/smallLabel"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:duplicateParentState="true"
                android:ellipsize="end"
                android:maxLines="1"
                android:textSize="@dimen/design_bottom_navigation_text_size"/>
            <TextView
                android:id="@+id/largeLabel"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:duplicateParentState="true"
                android:ellipsize="end"
                android:maxLines="1"
                android:textSize="@dimen/design_bottom_navigation_active_text_size"
                android:visibility="invisible"/>
          </com.google.android.material.internal.BaselineLayout>
        </merge>
    复制代码
    

    只需要获取到当前点击位置的 MenuItemView,随后通过此 View 获取到 id 为 icon 的 ImageView 顺便给其设置动画即可。

    关键代码如下:

        private fun handleCurrentItemAnimator(item: MenuItem) {
            val menuItemView =
                mSelfActivity.findViewById<BottomNavigationItemView>(
                    nav_bottom_bar.menu.findItem(
                        item.itemId
                    ).itemId
                )
            val menuIconView = menuItemView.findViewById<ImageView>(R.id.icon)
            handleWithAnimatorSet(
                handleBottomNavScaleAnimator(menuIconView, T_SCALE_X),
                handleBottomNavScaleAnimator(menuIconView, T_SCALE_Y),
                400
            )
        }
    复制代码
    

    对应的动画工具类方法:

        fun handleBottomNavScaleAnimator(viewTarget: View, propertyName: String): ObjectAnimator {
            return ObjectAnimator.ofFloat(viewTarget, propertyName, 1f, 1.1f, 0.9f, 1f)
        } 
    
        fun handleWithAnimatorSet(
            playAnimator: ObjectAnimator,
            afterAnimator: ObjectAnimator,
            duration: Long
        ) {
            val animSet = AnimatorSet()
            animSet.play(playAnimator)
                .with(afterAnimator)
            animSet.duration = duration
            animSet.start()
        }
    复制代码
    

    效果如下:

    4、选中时,还想来个震动,怎么搞?(2020/09/03)

    第一步,添加权限:

        <!-- 小震动 -->
        <uses-permission android:name="android.permission.VIBRATE" />
    复制代码
    

    第二步:开始震动

    震动前需要对当前设备判断是否存在震动器。

    /**
     * 开启震动
     */
    fun startDeviceVibrate(context: Context) {
        val vibrator = context.getSystemService(Service.VIBRATOR_SERVICE) as Vibrator
        // 判断当前设备是否有震动器
        if (vibrator.hasVibrator()) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                // long milliseconds:震动毫秒数, int amplitude:震动强度,该值必须介于 1 ~ 255 之间,或者 DEFAULT_AMPLITUDE
                vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE))
            }
        }
    }
    复制代码
    

    相关文章

      网友评论

        本文标题:Android Notes|BottomNavigationVi

        本文链接:https://www.haomeiwen.com/subject/ncxpektx.html