最近在写一个 6盘 的第三方客户端,设计上是想要在文件管理页面底部加一个 BottomAppBar,随滚动隐藏。因为需要实现上传文件/创建文件夹/传输列表,如果都是作为图标放在 BottomAppBar 里,感觉不美观,所以准备浓缩在 FloatingActionButton 里。

但是查了才发现 Google 官方虽然出了 FloatingActionMenu 的设计规格,但是 Material 包里却至今没实现(都麻了,不少设计规格里有的功能一直都还没做)。

找了几个第三方库,star 数多的基本都不再更新了。就算不更了,本来有侥幸心理想拿来看看能不能用,测试完发现,如果单单作为 FAB 那完全没问题,但是就没办法跟 BottomAppBar 好好联动了。

官方的 FloatingActionButton 是可以在加了 app:layout_anchor="@id/bottomAppBar" 的情况下,自动的让 BottomAppBar 顶部出现一个嵌合 FAB 的凹槽的,如果用了第三方库那没办法出现……

思考了一下,应该可以通过预先写定 FAB 菜单所需的 FAB 后,将其设置为 invisible,然后在点击主 FAB 时通过配合动画让它们出现,最终实现效果如下:

在此之前

由于 BottomAppBarFloatingActionButton 都是 Google Design 包里的控件,因此需要导包。

首先要在确保 Project 级build.gradle 中存在 google()

allprojects {
    repositories {
        google()
        jcenter()
        maven { url "https://jitpack.io" }
    }
}

接着在 Module 级build.gradle 中加入:

dependencies {
  implementation 'com.google.android.material:material:<version>'
}

<version> 可以参考 Google’s Maven Repository 或者 MVN Repository

动画文件

这里用到 Clans/FloatingActionButton@GitHub 项目里的四种动画,分别是:

  • 「按比例放大」「按比例缩小」(菜单 FAB)
  • 「从左到右进入」「从右到左进入」(主 FAB)

res 目录中新建 anim 文件夹,分别新建以下四个文件并填入对应内容。

fab_scale_up.xml

<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:interpolator/overshoot"
    android:fromXScale="0.0"
    android:toXScale="1.0"
    android:fromYScale="0.0"
    android:toYScale="1.0"
    android:pivotX="50%"
    android:pivotY="50%"
    android:duration="200" />

fab_scale_down.xml

<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:interpolator/accelerate_quint"
    android:fromXScale="1.0"
    android:toXScale="0.0"
    android:fromYScale="1.0"
    android:toYScale="0.0"
    android:pivotX="50%"
    android:pivotY="50%"
    android:duration="200" />

fab_slide_in_from_left.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:interpolator/overshoot">
    <alpha
        android:fromAlpha="0"
        android:toAlpha="1"
        android:duration="300" />
    <translate
        android:fromXDelta="-15%p"
        android:toXDelta="0"
        android:duration="200" />
</set>

fab_slide_in_from_right.xml


<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:interpolator/overshoot">
    <alpha
        android:fromAlpha="0"
        android:toAlpha="1"
        android:duration="300" />
    <translate
        android:fromXDelta="15%p"
        android:toXDelta="0"
        android:duration="200" />
</set>

布局文件

打开你要加入 BottomAppBar 的布局文件,向其中加入:

<?xml version="1.0" encoding="utf-8"?>
<......>
    <com.google.android.material.bottomappbar.BottomAppBar
        android:id="@+id/bottomAppBar"
        style="@style/Widget.MaterialComponents.BottomAppBar.Colored"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        app:hideOnScroll="true"
        app:menu="@menu/file_bottom_appbar"
        app:navigationContentDescription="@string/file_to_parent_folder"
        app:navigationIcon="@drawable/ic_baseline_keyboard_capslock_24" />

    <LinearLayout
        android:id="@+id/file_fab_menu_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:layout_marginBottom="100dp"
        android:gravity="center"
        android:orientation="vertical">

        <include layout="@layout/file_fab_upload_layout"/>
        <include layout="@layout/file_fab_create_folder_layout"/>
        <include layout="@layout/file_fab_transmission_layout"/>

    </LinearLayout>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/file_fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@string/file_upload"
        app:backgroundTint="@color/colorAccent"
        app:layout_anchor="@id/bottomAppBar"
        app:srcCompat="@drawable/ic_baseline_add_24"
        app:tint="@android:color/white" />
</......>

实际上就是一个 BottomAppBar 和一个 FloatingActionButton,另外再加了一个 LinearLayout 用来放菜单 FAB。控件涉及到的菜单、图标、内容描述、颜色等请根据实际情况自行修改。(注:为 BottomAppBar 添加 app:hideOnScroll="true" 属性可以在页面滑动时自动向下隐藏,但保留主 FAB)

由于我们调用了三个布局文件,这里也需要贴一下,因为三个基本上一样,只需要修改图标等就行,所以只贴一个。

file_fab_upload_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/file_fab_upload"
    android:visibility="invisible"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:layout_marginTop="4dp"
        android:layout_marginBottom="12dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/file_upload"
        android:layout_marginEnd="10dp"
        android:textSize="16sp"
        android:textColor="@android:color/white"
        android:background="@drawable/fab_label_style"
        android:padding="5dp"
        android:elevation="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/file_fab_upload_button"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/file_fab_upload_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:layout_marginBottom="12dp"
        android:animateLayoutChanges="true"
        android:contentDescription="@string/file_upload"
        android:visibility="visible"
        app:backgroundTint="@color/colorAccent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/ic_baseline_cloud_upload_24"
        app:tint="@android:color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>

其中所用到的 @drawable/fab_label_style 如下:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="#424242" />
    <corners android:radius="5dp" />
    <padding
        android:left="10dp"
        android:top="10dp"
        android:right="10dp"
        android:bottom="10dp" />
</shape>

Kotlin 代码

修改对应的 Activity 文件:

companion object {
    ...
    var isShowFabMenu = false
    lateinit var showAnimation: Animation
    lateinit var hideAnimation: Animation
    lateinit var showMenuAnimation: Animation
    lateinit var hideMenuAnimation: Animation
    ...
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.XXXXXXXXXX)
    initFAB()
    ...
}

private fun initFAB() {
    showAnimation = AnimationUtils.loadAnimation(this, R.anim.fab_scale_up)
    hideAnimation = AnimationUtils.loadAnimation(this, R.anim.fab_scale_down)
    showMenuAnimation = AnimationUtils.loadAnimation(this, R.anim.fab_slide_in_from_left)
    hideMenuAnimation = AnimationUtils.loadAnimation(this, R.anim.fab_slide_in_from_right)
    file_fab.setOnClickListener {
        if (!isShowFabMenu) {
            //还没显示菜单
            showMenu()
        } else {
            //显示菜单了
            hideMenu()
        }
    }
}

private fun showMenu() {
    file_fab.startAnimation(showMenuAnimation)
    file_fab.setImageDrawable(
        ContextCompat.getDrawable(
            this,
            R.drawable.ic_baseline_close_24
        )
    )
    file_fab_upload.startAnimation(showAnimation)
    file_fab_create_folder.startAnimation(showAnimation)
    file_fab_transmission.startAnimation(showAnimation)
    file_fab_upload.visibility = View.VISIBLE
    file_fab_create_folder.visibility = View.VISIBLE
    file_fab_transmission.visibility = View.VISIBLE
    isShowFabMenu = true
}

private fun hideMenu() {
    file_fab.startAnimation(hideMenuAnimation)
    file_fab.setImageDrawable(
        ContextCompat.getDrawable(
            this,
            R.drawable.ic_baseline_add_24
        )
    )
    file_fab_upload.startAnimation(hideAnimation)
    file_fab_create_folder.startAnimation(hideAnimation)
    file_fab_transmission.startAnimation(hideAnimation)
    file_fab_upload.visibility = View.INVISIBLE
    file_fab_create_folder.visibility = View.INVISIBLE
    file_fab_transmission.visibility = View.INVISIBLE
    isShowFabMenu = false
}

监听主 FAB 的点击事件,并预先用 isShowFabMenu 作为 FLAG 来判断当前 FAB 菜单是否展开。

  • 未展开,则执行 showMenu() 方法,为主 FAB 设置「从左到右进入」动画,并且把图标改成 ×。同时为菜单 FAB 各设置「按比例放大」,并使其 visibility 变为 VISIBLE。最后将 FLAG 设置为 true,表明当前菜单已展开。
  • 展开,则执行 hideMenu() 方法,为主 FAB 设置「从右到左进入」动画,并且把图标改回 +。同时为菜单 FAB 各设置「按比例缩小」,并使其 visibility 变为 INVISIBLE。最后将 FLAG 设置为 false,表明当前菜单已不再展开。