专栏名称: 鸿洋
你好,欢迎关注鸿洋的公众号,每天为您推送高质量文章,让你每天都能涨知识。点击历史消息,查看所有已推送的文章,喜欢可以置顶本公众号。此外,本公众号支持投稿,如果你有原创的文章,希望通过本公众号发布,欢迎投稿。
目录
相关文章推荐
郭霖  ·  Android外接设备开发使用一网打尽 ·  4 天前  
stormzhang  ·  打工人可以薅点羊毛​了 ·  3 天前  
stormzhang  ·  又被平均了? ·  4 天前  
鸿洋  ·  安卓应用跳转回流的统一和复用 ·  1 周前  
51好读  ›  专栏  ›  鸿洋

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

鸿洋  · 公众号  · android  · 2024-11-25 08:35

正文

链接:https://juejin.cn/post/7337354931480199208
本文由作者授权发布

前言

底部导航栏相信大部分的Androider都不陌生,毕竟对于绝大多数的应用来说底部导航栏是首页的标配,也不缺各种花里胡哨不按常理出牌的底部导航栏。例如在我某天路过看到同事搞了个下面这样的:
我:咦?这种中间的FAB直接凹陷下去的效果你是怎么实现的,之前还没搞过这样的还真有点新奇hhh
同事:UI提供的切图呗,图片原本就是中间凹下去的,直接设成background不就行了,这有多难?
我:......?如果你的FAB移动了,导航栏怎么跟着变化?
同事:没得怎么变化,反正需求没有说要加动画
我:那要是PM要你的导航栏凹陷深度依赖于FAB的位置大小,你要怎么处理?
同事:......那阁下又当如何应对?(摆烂)

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

1
设计思路


既然玩那就干脆玩花一点,一步到位给中间按钮加了个简单的点击动画,点击后FAB在垂直方向上执行一次往返位移,同时底部导航栏上的凹槽大小跟随着FAB的凹陷深度动态变化,需要实现的功能点以及思路大体是下面的几个:
  • 导航栏与页面跳转:使用谷歌官方提供的现成组件BottomNavigationView+Navigation组件+Fragment的方式来实现;
  • FAB停靠导航栏:利用协调者布局CoordinatorLayout的特性,设置底部导航栏作为FAB的参照物方便对齐停靠;
  • FAB位移动画以及导航栏凹陷动态变化:自定义导航栏的形状,根据FAB的凹陷深度来动态绘制导航栏。
捋好了思路,话不多说立马开干!

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

2
实现过程


导航栏与页面跳转

由于谷歌官方有现成的导航相关组件BottomNavigationView和Navigation组件,一般来说如果没什么特殊需求的话只需要自己定义下导航路由图和底部导航菜单menu文件,定义导航item以及每个item对应的页面使用Fragment组件来实现,页面跳转、item切换动画等的相关功能都是现成的,方便快捷。
当然了实际上不用那么麻烦一点点手动创建,贴心的AS直接有提供一键生成以上文件的快捷方式,相关依赖也会自动导入,只需新建Activity时选择Bottom Navigation Views Activity:
创建好了带导航栏的Activity后界面默认是这样子的效果:
接下来就是根据需求在小细节上修修补补了,由于只需要显示两个导航item,另外需要在导航栏的中间给大按钮预留个空位,于是在导航栏的menu文件中将中间item的图标和文字都去掉,并将enabled设成false,禁用点击事件即可:
//bottom_nav_menu.xml

<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>


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

导航栏中间大按钮停靠

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

<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>


当前效果:

导航栏凹陷效果绘制

前面的工作还是比较简单的,接下来才是重头戏:需要在导航栏上绘制出凹陷的区域。对于这样的效果我决定老老实实选择自定义BottomNavigationView,为所欲为哈哈哈!只不过这看似挺简单的效果,设计路径和计算相关尺寸大小实践起来还是挺麻烦的,在废弃了n种方案之后决定出采用以下的一种:
如上图所示,橙色实线为底部导航栏的目标形状,canvas的绘制原点默认在左上角,整个形状的直线部分路径比较好确定,中间凹陷的部分我设计成由两段半径为radiusCorner的圆弧和一段半径为radiusCentral的圆弧拼接而成,另外中间圆的圆心到x轴的距离大小假设为distance,两旁的圆心和中间的圆心之间的直线与x轴的夹角大小设计成30°,有了这些变量之后由此可以直接得出一些尺寸值:
接下来把圆心坐标都确定下来,那不就完事了!!查了一波已经还给了老师的正弦余弦公式,可以知道:
sin(30°)=1/2,
cos(30°)=√3/2
由此可以得出三个圆心坐标:
完美!到这里带凹槽的导航栏已经是呼之欲出了!!唉慢着,这凹槽的深度不是还得跟随按钮的位置动态变化吗,那这些坐标又当如何变动??老铁别急,下面继续来分析。
假设按钮在垂直方向上的当前位移距离大小为d,当按钮向上运动时导航栏上的凹槽应该往中间收缩,在收缩过程中保持两旁小圆半径大小和30°夹角不变,这时另中间圆的圆心同步在垂直方向上移动-d,动态修改distance的值,由此一来可以达到凹槽收缩的效果,按钮向下运动时同理:
另外还需要考虑按钮完全位于导航栏上方时的情况,这种情况下直接使用直线来代替原来的曲线部分。话不多说,直接上代码:
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<FloatFloat>
        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) }
    }
}


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

中间按钮位移动画

按钮的点击事件定义如下:
    @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秒,在按钮运动的同时监听按钮的位移值,并根据当前位移值更新重绘导航栏凹槽。

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

3
最终效果图


推荐阅读

Android Native内存越多,会不会触发GC?
鸿蒙纪·系列教程#03 | 沉浸状态栏与资源使用
Android系统native进程之我是installd进程


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

推荐文章
stormzhang  ·  打工人可以薅点羊毛​了
3 天前
stormzhang  ·  又被平均了?
4 天前
经典短篇阅读小组  ·  浪费时间和金钱,人人都会
8 年前
经典短篇阅读小组  ·  浪费时间和金钱,人人都会
8 年前
孤读先生  ·  “爱这样的人,我真的怕了”
7 年前