自定义View:手撸一个带FAB凹槽的底部导航栏

一、前言

底部导航栏相信大部分的Androider都不陌生,毕竟对于绝大多数的应用来说底部导航栏是首页的标配,也不缺各种花里胡哨不按常理出牌的底部导航栏。例如在我某天路过看到同事搞了个下面这样的:

我:咦?这种中间的FAB直接凹陷下去的效果你是怎么实现的,之前还没搞过这样的还真有点新奇hhh

同事:UI提供的切图呗,图片原本就是中间凹下去的,直接设成background不就行了,这有多难?

我:......?如果你的FAB移动了,导航栏怎么跟着变化?

同事:没得怎么变化,反正需求没有说要加动画

我:那要是PM要你的导航栏凹陷深度依赖于FAB的位置大小,你要怎么处理?

同事:......那阁下又当如何应对?(摆烂)

emmmmm.....好了成功激起了我的好奇心,横竖现下手头上没啥要紧的活,那就自己手撸一个来玩玩hhhh!

二、设计思路

既然玩那就干脆玩花一点,一步到位给中间按钮加了个简单的点击动画,点击后FAB在垂直方向上执行一次往返位移,同时底部导航栏上的凹槽大小跟随着FAB的凹陷深度动态变化,需要实现的功能点以及思路大体是下面的几个:

  • 导航栏与页面跳转:使用谷歌官方提供的现成组件BottomNavigationView+Navigation组件+Fragment的方式来实现;
  • FAB停靠导航栏:利用协调者布局CoordinatorLayout的特性,设置底部导航栏作为FAB的参照物方便对齐停靠;
  • FAB位移动画以及导航栏凹陷动态变化:自定义导航栏的形状,根据FAB的凹陷深度来动态绘制导航栏。

捋好了思路,话不多说立马开干!

(主要涉及:BottomNavigationView Navigation Fragment Canvas Path Animation CoordinatorLayout)

三、实现过程

3.1 导航栏与页面跳转

由于谷歌官方有现成的导航相关组件BottomNavigationView和Navigation组件,一般来说如果没什么特殊需求的话只需要自己定义下导航路由图和底部导航菜单menu文件,定义导航item以及每个item对应的页面使用Fragment组件来实现,页面跳转、item切换动画等的相关功能都是现成的,方便快捷。

当然了实际上不用那么麻烦一点点手动创建,贴心的AS直接有提供一键生成以上文件的快捷方式,相关依赖也会自动导入,只需新建Activity时选择Bottom Navigation Views Activity

创建好了带导航栏的Activity后界面默认是这样子的效果:

接下来就是根据需求在小细节上修修补补了,由于只需要显示两个导航item,另外需要在导航栏的中间给大按钮预留个空位,于是在导航栏的menu文件中将中间item的图标和文字都去掉,并将enabled设成false,禁用点击事件即可:

kotlin 复制代码
//bottom_nav_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/navigation_home"
        android:icon="@drawable/ic_home_black_24dp"
        android:title="@string/title_home" />

    <item
        android:id="@+id/navigation_dashboard"
        android:enabled="false"
        android:title="" />

    <item
        android:id="@+id/navigation_notifications"
        android:icon="@drawable/ic_notifications_black_24dp"
        android:title="@string/title_notifications" />
</menu>

到这一步底部导航栏跟页面的基本交互也算完成了:

3.2 导航栏中间大按钮停靠

在之前已经在导航栏上留好了放置大按钮的位置,接下来就是想办法把这个按钮塞进去,并且设置按钮的中心点与导航栏的顶部居中对齐。考虑到这个按钮需要显示在其他控件的最上层,而且需要以导航栏为参照物来确定位置,利用CoordinatorLayout的特性正好可以很方便地实现,于是将整个Activity的布局文件修改如下:

kotlin 复制代码
//activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="?attr/actionBarSize">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <fragment
            android:id="@+id/nav_host_fragment_activity_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toTopOf="@id/nav_view"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/mobile_navigation" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/nav_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="0dp"
            android:layout_marginEnd="0dp"
            android:background="@android:color/transparent"
            app:elevation="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/nav_host_fragment_activity_main"
            app:menu="@menu/bottom_nav_menu" />
    </androidx.constraintlayout.widget.ConstraintLayout>

    <ImageView
        android:id="@+id/fab"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:src="@drawable/ic_fab"
        app:layout_anchor="@id/nav_view"
        app:layout_anchorGravity="center_horizontal|top" /
</androidx.coordinatorlayout.widget.CoordinatorLayout>

当前效果:

3.3 导航栏凹陷效果绘制

前面的工作还是比较简单的,接下来才是重头戏:需要在导航栏上绘制出凹陷的区域。对于这样的效果我决定老老实实选择自定义BottomNavigationView,为所欲为哈哈哈!只不过这看似挺简单的效果,设计路径和计算相关尺寸大小实践起来还是挺麻烦的,在废弃了n种方案之后决定出采用以下的一种:

如上图所示,橙色实线为底部导航栏的目标形状,canvas的绘制原点默认在左上角,整个形状的直线部分路径比较好确定,中间凹陷的部分我设计成由两段半径为radiusCorner的圆弧和一段半径为radiusCentral的圆弧拼接而成,另外中间圆的圆心到x轴的距离大小假设为distance,两旁的圆心和中间的圆心之间的直线与x轴的夹角大小设计成30°,有了这些变量之后由此可以直接得出一些尺寸值:

接下来把圆心坐标都确定下来,那不就完事了!!查了一波已经还给了老师的正弦余弦公式,可以知道:

sin(30°)=1/2, cos(30°)=√3/2

由此可以得出三个圆心坐标:

完美!到这里带凹槽的导航栏已经是呼之欲出了!!唉慢着,这凹槽的深度不是还得跟随按钮的位置动态变化吗,那这些坐标又当如何变动??老铁别急,下面继续来分析。

假设按钮在垂直方向上的当前位移距离大小为d,当按钮向上运动时导航栏上的凹槽应该往中间收缩,在收缩过程中保持两旁小圆半径大小和30°夹角不变,这时另中间圆的圆心同步在垂直方向上移动-d,动态修改distance的值,由此一来可以达到凹槽收缩的效果,按钮向下运动时同理:

另外还需要考虑按钮完全位于导航栏上方时的情况,这种情况下直接使用直线来代替原来的曲线部分。话不多说,直接上代码:

kotlin 复制代码
class MyBottomNavView : BottomNavigationView {
    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    private val paint by lazy {
        Paint().apply {
            isAntiAlias = true
            color = Color.parseColor("#ffcecece")
            strokeWidth = 5F
            style = Paint.Style.STROKE
        }
    }

    private var distance = Constants.DEFAULT_DISTANCE //默认初始值为50
    private val radiusCorner = Constants.RADIUS_CORNER
    private val radiusCentral: Float
        get() = radiusCorner + 2 * distance
    private val circleCenter: Pair<Float, Float>
        get() = (width.toFloat() / 2) to -distance

    @RequiresApi(Build.VERSION_CODES.O)
    fun updateDistance(d: Float, canvas: Canvas) {
        distance = Constants.DEFAULT_DISTANCE - d
        this.draw(canvas)
        this.invalidate()
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun drawBackground(canvas: Canvas) {
        val leftCenter = (circleCenter.first - sqrt(3f) * (radiusCorner + distance)) to radiusCorner
        val rightCenter =
            (circleCenter.first + sqrt(3f) * (radiusCorner + distance)) to radiusCorner
        val bgPath = Path().apply {
            moveTo(0f, 0f)
            if (distance >= -10f) {
                lineTo(leftCenter.first, 0f)
                arcTo(
                    leftCenter.first - radiusCorner,
                    0f,
                    leftCenter.first + radiusCorner,
                    2 * radiusCorner,
                    -90f,
                    60f,
                    true
                )
                arcTo(
                    circleCenter.first - radiusCentral,
                    circleCenter.second - radiusCentral,
                    circleCenter.first + radiusCentral,
                    circleCenter.second + radiusCentral,
                    150f,
                    -120f,
                    true
                )
                arcTo(
                    rightCenter.first - radiusCorner,
                    0f,
                    rightCenter.first + radiusCorner,
                    2 * radiusCorner,
                    -150f,
                    60f,
                    true
                )
                lineTo(width.toFloat(), 0f)
            } else {
                lineTo(width.toFloat(), 0f)
            }
        }

        canvas.apply {
            save()
            drawPath(bgPath, paint)
            restore()
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    override fun draw(canvas: Canvas?) {
        super.draw(canvas)
        canvas?.let { drawBackground(it) }
    }
}

如上面的代码所示,重写自定义BottomNavigationView的onDraw方法来绘制凹陷效果,外部通过调用updateDistance方法来更新中间圆心的位置并重绘导航栏的形状。

3.4 中间按钮位移动画

按钮的点击事件定义如下:

kotlin 复制代码
    @RequiresApi(Build.VERSION_CODES.O)
    private fun onFabClick() {
        val objectAnimation = ObjectAnimator.ofFloat(
            binding.fab,
            "translationY",
            0f,
            -binding.fab.height.toFloat() + 30f,
            0f
        ).apply {
            duration = 4000
            repeatMode = ValueAnimator.REVERSE
            addUpdateListener {
                updateJob = lifecycleScope.launch {
                    binding.navView.updateDistance(abs(it.animatedValue as Float), Canvas())
                }
            }
            addListener(onEnd = {
                updateJob?.cancel()
            })
        }
        objectAnimation.start()
    }

代码逻辑很简单,onFabClick方法被触发时,按钮会在垂直方向上在给定的运动区间内做一次往返位移,动画持续时长为4秒,在按钮运动的同时监听按钮的位移值,并根据当前位移值更新重绘导航栏凹槽。

终于大功告成!!完结撒花!!

四、最终效果图

PS:本人现学现卖撸了个简单的导航栏效果,在某些细节上可能还有改进优化的空间,欢迎各位大佬提出更优的方案,欢迎大家点赞鼓励hhh

相关推荐
一起搞IT吧2 小时前
相机Camera日志实例分析之五:相机Camx【萌拍闪光灯后置拍照】单帧流程日志详解
android·图像处理·数码相机
浩浩乎@3 小时前
【openGLES】安卓端EGL的使用
android
Kotlin上海用户组4 小时前
Koin vs. Hilt——最流行的 Android DI 框架全方位对比
android·架构·kotlin
zzq19964 小时前
Android framework 开发者模式下,如何修改动画过度模式
android
木叶丸5 小时前
Flutter 生命周期完全指南
android·flutter·ios
阿幸软件杂货间5 小时前
阿幸课堂随机点名
android·开发语言·javascript
没有了遇见5 小时前
Android 渐变色整理之功能实现<二>文字,背景,边框,进度条等
android
没有了遇见6 小时前
Android RecycleView 条目进入和滑出屏幕的渐变阴影效果
android
站在巨人肩膀上的码农6 小时前
去掉长按遥控器power键后提示关机、飞行模式的弹窗
android·安卓·rk·关机弹窗·power键·长按·飞行模式弹窗
呼啦啦--隔壁老王7 小时前
屏幕旋转流程
android