一、前语

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

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

我:咦?这种中心的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

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

创建好了带导航栏的Activity后界面默许是这样子的作用:

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

接下来便是依据需求在小细节上修修补补了,因为只需求显现两个导航item,别的需求在导航栏的中心给大按钮预留个空位,所以在导航栏的menu文件中将中心item的图标和文字都去掉,并将enabled设成false,禁用点击事件即可:

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

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

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

3.2 导航栏中心大按钮停靠

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

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

当时作用:

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

3.3 导航栏洼陷作用制作

前面的工作仍是比较简略的,接下来才是重头戏:需求在导航栏上制作出洼陷的区域。关于这样的作用我决议老老实实挑选自界说BottomNavigationView,随心所欲哈哈哈!只不过这看似挺简略的作用,规划路径和核算相关尺度巨细实践起来仍是挺费事的,在抛弃了n种方案之后决议出采用以下的一种:

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

如上图所示,橙色实线为底部导航栏的目标形状,canvas的制作原点默许在左上角,整个形状的直线部分路径比较好确认,中心洼陷的部分我规划成由两段半径为radiusCorner的圆弧和一段半径为radiusCentral的圆弧拼接而成,别的中心圆的圆心到x轴的间隔巨细假定为distance,两旁的圆心和中心的圆心之间的直线与x轴的夹角巨细规划成30,有了这些变量之后由此能够直接得出一些尺度值:

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

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

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

由此能够得出三个圆心坐标:

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

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

假定按钮在垂直方向上的当时位移间隔巨细为d,当按钮向上运动时导航栏上的凹槽应该往中心缩短,在缩短进程中保持两旁小圆半径巨细和30夹角不变,这时另中心圆的圆心同步在垂直方向上移动-d,动态修正distance的值,由此一来能够达到凹槽缩短的作用,按钮向下运动时同理:

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

别的还需求考虑按钮完全坐落导航栏上方时的情况,这种情况下直接运用直线来代替原来的曲线部分。话不多说,直接上代码:

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 中心按钮位移动画

按钮的点击事件界说如下:

    @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秒,在按钮运动的一起监听按钮的位移值,并依据当时位移值更新重绘导航栏凹槽。

终于功德圆满!!完结撒花!!

四、终究作用图

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

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