链接:https://juejin.cn/post/7337354931480199208 本文由作者授权发布
底部导航栏相信大部分的Androider都不陌生,毕竟对于绝大多数的应用来说底部导航栏是首页的标配,也不缺各种花里胡哨不按常理出牌的底部导航栏。例如在我某天路过看到同事搞了个下面这样的:
我:咦?这种中间的FAB直接凹陷下去的效果你是怎么实现的,之前还没搞过这样的还真有点新奇hhh
同事:UI提供的切图呗,图片原本就是中间凹下去的,直接设成background不就行了,这有多难?
我:......?如果你的FAB移动了,导航栏怎么跟着变化?
同事:没得怎么变化,反正需求没有说要加动画
我:那要是PM要你的导航栏凹陷深度依赖于FAB的位置大小,你要怎么处理?
同事:......那阁下又当如何应对?(摆烂)
emmmmm.....好了成功激起了我的好奇心,横竖现下手头上没啥要紧的活,那就自己手撸一个来玩玩hhhh!
既然玩那就干脆玩花一点,一步到位给中间按钮加了个简单的点击动画,点击后FAB在垂直方向上执行一次往返位移,同时底部导航栏上的凹槽大小跟随着FAB的凹陷深度动态变化,需要实现的功能点以及思路大体是下面的几个:
捋好了思路,话不多说立马开干!
(主要涉及:BottomNavigationView Navigation Fragment Canvas Path Animation CoordinatorLayout)
由于谷歌官方有现成的导航相关组件BottomNavigationView和Navigation组件,一般来说如果没什么特殊需求的话只需要自己定义下导航路由图和底部导航菜单menu文件,定义导航item以及每个item对应的页面使用Fragment组件来实现,页面跳转、item切换动画等的相关功能都是现成的,方便快捷。
当然了实际上不用那么麻烦一点点手动创建,贴心的AS直接有提供一键生成以上文件的快捷方式,相关依赖也会自动导入,只需新建Activity时选择Bottom Navigation Views Activity:
创建好了带导航栏的Activity后界面默认是这样子的效果:
接下来就是根据需求在小细节上修修补补了,由于只需要显示两个导航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>
到这一步底部导航栏跟页面的基本交互也算完成了
在之前已经在导航栏上留好了放置大按钮的位置,接下来就是想办法把这个按钮塞进去,并且设置按钮的中心点与导航栏的顶部居中对齐。考虑到这个按钮需要显示在其他控件的最上层,而且需要以导航栏为参照物来确定位置,利用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>
当前效果:
前面的工作还是比较简单的,接下来才是重头戏:需要在导航栏上绘制出凹陷的区域。对于这样的效果我决定老老实实选择自定义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<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方法来更新中间圆心的位置并重绘导航栏的形状。
按钮的点击事件定义如下:
@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秒,在按钮运动的同时监听按钮的位移值,并根据当前位移值更新重绘导航栏凹槽。
终于大功告成!!完结撒花!!