Material Design 实战(五):可折叠式标题栏

357 阅读5分钟

前言

书接上回...

现在,我们的标题栏虽然是 Toolbar,但它看起来和传统的 ActionBar 并没有什么区别。它只能随着 RecyclerView 列表的滚动而滚动。实际上,我们可以定制标题栏的样式。

接下来,我们就借助 CollapsingToolbarLayout 来实现一个可折叠的标题栏。

CollapsingToolbarLayout

CollapsingToolbarLayout 是一个由 Material 库提供的布局,它可以丰富 Toolbar 的效果。我们来看看它的基本用法。

CollapsingToolbarLayout 并不能单独存在,它必须作为 AppBarLayout 的直接子布局。而 AppBarLayout 又必须是 CoordinatorLayout 的子布局

我们开始实现。首先,创建一个 FruitActivity 作为水果详情界面,其布局名为 activity_fruit.xml

实现水果标题栏

先来实现标题栏部分的布局。

首先,使用 CoordinatorLayout 作为最外层布局。它可协调其子 View 的动作。

<?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:layout_width="match_parent"
    android:layout_height="match_parent">
    
</androidx.coordinatorlayout.widget.CoordinatorLayout>

其中定义了 xmlns:app 命名空间。

然后,在 CoordinatorLayout 中放置一个 AppBarLayout,在 AppBarLayout 中放置一个 CollapsingToolbarLayout

<?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:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBar"
        android:layout_width="match_parent"
        android:layout_height="250dp">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsingToolbar"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

其中 app:contentScrim 属性用于指定当 CollapsingToolbarLayout 折叠成普通标题栏时显示的背景色。

app:layout_scrollFlags 属性用于指定 AppBarLayout 响应滚动事件的逻辑。scroll 表示可滚动,没有这个标志其他的标志都不生效。exitUntilCollapsed 表示 AppBarLayout 会随着内容的滚动而向上滚动,直到完全折叠,然后固定在顶部。

现在,我们在 CollapsingToolbarLayout 中添加标题栏内容:一张图片和一个普通的 Toolbar

<ImageView
    android:id="@+id/fruitImageView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:contentDescription="@string/fruit_image_description"
    android:scaleType="centerCrop"
    app:layout_collapseMode="parallax" />

<androidx.appcompat.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    app:layout_collapseMode="pin" />

其中的 app:layout_collapseMode 属性是用于指定 CollapsingToolbarLayout 内部的子 View 在折叠过程中的行为。

  • pin 表示在折叠过程中,Toolbar 的位置始终保持不变,“钉”在内容界面的顶部。

  • parallax 表示在折叠过程中,图片会比内容滚动得更慢进行移动,增加视觉层次感。

实现水果内容详情

下面实现内容详情部分的布局。

我们在 CoordinatorLayout 中,AppBarLayout 的下方(同层级),添加一个 NestedScrollView

<androidx.core.widget.NestedScrollView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <com.google.android.material.card.MaterialCardView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="15dp"
            android:layout_marginTop="35dp"
            android:layout_marginRight="15dp"
            android:layout_marginBottom="15dp"
            app:cardCornerRadius="4dp">

            <TextView
                android:id="@+id/fruitContentText"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="10dp" />
        </com.google.android.material.card.MaterialCardView>

    </LinearLayout>
</androidx.core.widget.NestedScrollView>

这样 AppBarLayout(标题栏)便能响应 NestedScrollView 的滚动,这是因为我们通过 app:layout_behavior 属性给 NestedScrollView 指定了一个 Behavior。它会在 NestedScrollView 滚动时,让 CoordinatorLayout 捕获滚动事件并通知给 AppBarLayout 做出响应。

最后,我们添加一个表示评论的悬浮按钮,它也能和 CoordinatorLayout 协作。

<?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:layout_width="match_parent"
    android:layout_height="match_parent">

    ...

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="评论"
        android:src="@drawable/ic_comment"
        app:layout_anchor="@id/appBar"
        app:layout_anchorGravity="bottom|end" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

其中使用了 app:layout_anchor 属性设置了一个锚点在 appBar 上,并使用 app:layout_anchorGravity 属性进行定位。这样 CoordinatorLayout 能够调度悬浮按钮的行为,让其根据 appBar 的位置变化自动调整自身的位置,从而始终处于标题栏区域的右下角。

实现水果详情页的功能逻辑

水果详情页的布局编写完后,我们现在来 FruitActivity 中实现逻辑。

class FruitActivity : AppCompatActivity() {

    companion object {
        const val FRUIT_NAME = "fruit_name"
        const val FRUIT_IMAGE_ID = "fruit_image_id"
    }

    private lateinit var binding: ActivityFruitBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityFruitBinding.inflate(layoutInflater)
        setContentView(binding.root)


        // 设置操作栏
        setSupportActionBar(binding.toolbar)
        // 显示 Home 按钮,默认为返回箭头
        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        // 获取传入的水果名称和水果图片资源 id
        val fruitName = intent.getStringExtra(FRUIT_NAME) ?: ""
        val fruitImageId = intent.getIntExtra(FRUIT_IMAGE_ID, 0)
        
        // 填充到界面中
        binding.collapsingToolbar.title = fruitName
        Glide.with(this).load(fruitImageId).into(binding.fruitImageView)
        binding.fruitContentText.text = generateFruitContent(fruitName)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            // 点击了 Home 按钮
            android.R.id.home -> {
                finish()
                return true
            }
        }
        return super.onOptionsItemSelected(item)
    }

    private fun generateFruitContent(fruitName: String) = fruitName.repeat(500)
}

为了能从列表中跳转到 FruitActivity,我们需要给 RecyclerView 列表子项注册点击事件。

// FruitAdapter.kt
class FruitAdapter :
    ListAdapter<Fruit, FruitAdapter.ViewHolder>(FruitDiffCallback) {

    inner class ViewHolder(private val binding: FruitItemBinding) :
        RecyclerView.ViewHolder(binding.root) {
        init {
            // 在 ViewHolder 初始化时设置监听器
            binding.root.setOnClickListener {
                val position = bindingAdapterPosition // 获取 ViewHolder 在适配器中的位置
                // 检查 position 是否有效
                if (position != RecyclerView.NO_POSITION) {
                    // 携带数据跳转到 FruitActivity
                    val fruit = getItem(position)
                    val intent = Intent(binding.root.context, FruitActivity::class.java).apply {
                        putExtra(FruitActivity.FRUIT_NAME, fruit.name)
                        putExtra(FruitActivity.FRUIT_IMAGE_ID, fruit.imageId)
                    }
                    binding.root.context.startActivity(intent)
                }
            }
        }

        ...
    }


    ...
}

运行程序,点击任意一个水果,即可看到:

image.gif

充分利用系统状态栏空间

虽然这样效果已经很不错了,但我们还可以让背景图片延伸到系统状态栏下方,实现沉浸式体验。

首先,在 FruitActivityonCreate() 方法中调用 enableEdgeToEdge() 方法。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // 让内容布局延伸到系统栏(状态栏、导航栏)
    enableEdgeToEdge()
   
    ...
}

这样会导致一个问题:状态栏会遮挡 Toolbar 的部分区域。为此,我们需要在 FruitActivity 中添加如下内容:

class FruitActivity : AppCompatActivity() {

    ...

    // 防止 Insets 多次应用
    private var insetsApplied = false
    
    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        ViewCompat.setOnApplyWindowInsetsListener(binding.appBar) { _, windowInsets ->
            if (!insetsApplied) {
                // 获取状态栏的高度
                val statusBarHeight =
                    windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()).top

                // 增加 Toolbar 的物理高度,让它有足够空间
                val toolbar = binding.toolbar
                val originalToolbarHeight = toolbar.layoutParams.height
                toolbar.layoutParams.height = originalToolbarHeight + statusBarHeight

                // 为 Toolbar 设置顶部内边距
                toolbar.setPadding(
                    toolbar.paddingLeft,
                    statusBarHeight,
                    toolbar.paddingRight,
                    toolbar.paddingBottom
                )

                // 为 CollapsingToolbarLayout 设置最小高度,以适应 Toolbar
                binding.collapsingToolbar.minimumHeight = originalToolbarHeight + statusBarHeight

                // 标记为已处理,防止重复执行
                insetsApplied = true
            }

            // 返回原始 insets,不做消耗
            windowInsets
        }

    }

    ...
}

再次运行程序,你将看到:

image.png
image.png