Material Design 实战(四):卡片式布局 MaterialCardView 与下拉刷新

522 阅读7分钟

前言

书接上回...

现在,应用的内容区域还存在着大片空白,我们来使用一些水果图片填充这块区域,构建一个列表界面。

在这个过程中,我们将学习卡片式布局,它可以让元素看起来像一张张卡片,拥有圆角和阴影。然后,了解如何结合 CoordinatorLayoutAppBarLayout 实现可滚动折叠的顶部工具栏。最后,为列表加入下拉刷新的功能。

卡片式布局

MaterialCardView

要实现卡片效果,可以使用 Material 提供的 MaterialCardView 控件。你可以把它看作是一个 FrameLayout,只是额外拥有圆角、阴影等效果,让我们能够轻松实现富有层次感的界面。

它的基本使用很简单,只需在布局中添加如下内容:

<com.google.android.material.card.MaterialCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="8dp"
    app:cardElevation="4dp">

    <TextView
        android:id="@+id/infoText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</com.google.android.material.card.MaterialCardView>

我们通过 app:cardCornerRadius 属性指定了卡片圆角的弧度,并通过 app:cardElevation 属性指定了卡片模拟 Z 轴的高度阴影。

准备工作:依赖配置

首先,我们要用到 View Binding,用于安全、便捷地访问视图控件。然后,为了能够动态展示多张卡片,我们需要用到 RecyclerView 列表控件。同时,为了高效、安全地加载图片,我们会用到 Glide 图片加载开源库。最后,我们添加下拉刷新控件的依赖。

app/build.gradle.kts 文件中配置如下内容:

android {
    buildFeatures {
        // 启用视图绑定
        viewBinding = true
    }
}

dependencies {
    // RecyclerView 控件
    implementation("androidx.recyclerview:recyclerview:1.4.0")
    // 图片加载库 Glide
    implementation("com.github.bumptech.glide:glide:4.16.0")
    // 下拉刷新控件
    implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
}

实现水果列表

现在,我们就来开始实现。首先,在主布局 activity_main.xmlCoordinatorLayout 中放置一个 RecyclerView。代码如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.MaterialComponents.Light" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:contentDescription="完成操作"
            android:src="@drawable/ic_done"
            app:elevation="8dp" />
        
    </androidx.coordinatorlayout.widget.CoordinatorLayout>

    <com.google.android.material.navigation.NavigationView
        android:id="@+id/navView"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:headerLayout="@layout/nav_header"
        app:menu="@menu/nav_menu" />

</androidx.drawerlayout.widget.DrawerLayout>

然后,定义水果的数据类 Fruit,用于存放水果名称和图片资源 id:

data class Fruit(val name: String, val imageId: Int)

创建列表项的布局 res/layout/fruit_item.xml 文件。代码如下:

<?xml version="1.0" encoding="utf-8"?>
<!--子项的最外层布局-->
<com.google.android.material.card.MaterialCardView 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="wrap_content"
    android:layout_margin="5dp"
    app:cardCornerRadius="4dp">

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

        <!--水果图片-->
        <ImageView
            android:id="@+id/fruitImage"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:contentDescription="水果图片"
            android:scaleType="centerCrop" />

        <!--水果名称-->
        <TextView
            android:id="@+id/fruitName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_margin="5dp"
            android:textSize="16sp" />
    </LinearLayout>
</com.google.android.material.card.MaterialCardView>

其中 android:scaleType="centerCrop",能让图片等比例缩放,填满整个 ImageView 的控件,并裁剪掉多余、超过边界的部分。

接着,定义适配器类 FruitAdapter。我们让它继承自 ListAdapter,因为 ListAdapter 内置了 DiffUtil,能够智能地计算数据差异,从而实现高效的列表刷新,避免 notifyDataSetChanged() 方法带来的性能问题。代码如下:

// 创建一个 DiffUtil.ItemCallback 对象,告诉 ListAdapter 数据比较的规则
object FruitDiffCallback : DiffUtil.ItemCallback<Fruit>() {
    // 判断两个列表项数据对象是否为同一个
    override fun areItemsTheSame(oldItem: Fruit, newItem: Fruit): Boolean {
        return oldItem.name == newItem.name && oldItem.imageId == newItem.imageId
    }

    // 判断两个列表项数据对象的内容是否没变
    override fun areContentsTheSame(oldItem: Fruit, newItem: Fruit): Boolean {
        return oldItem == newItem
    }
}


// 无需接收列表
class FruitAdapter :
    ListAdapter<Fruit, FruitAdapter.ViewHolder>(FruitDiffCallback) {

    inner class ViewHolder(private val binding: FruitItemBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(fruit: Fruit) {
            binding.fruitName.text = fruit.name
            Glide.with(binding.root.context)
                // 加载图片
                .load(fruit.imageId)
                // 设置到 ImageView 中
                .into(binding.fruitImage)
        }
    }


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = FruitItemBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
        return ViewHolder(binding)
    }


    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        // 使用 ListAdapter 提供的 getItem() 方法来获取数据
        val currentFruit = getItem(position)
        holder.bind(currentFruit)
    }
}

注意,其中我们使用了 Glide 来加载图片,而不是调用 ImageViewsetImageResource() 方法来加载。

最后,来到 MainActivity 中,准备数据并设置到 RecyclerView 列表中。代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var adapter: FruitAdapter
    
    private val fruits = listOf(
        Fruit("Apple", R.drawable.apple), Fruit("Banana", R.drawable.banana),
        Fruit("Orange", R.drawable.orange), Fruit("Watermelon", R.drawable.watermelon),
        Fruit("Pear", R.drawable.pear), Fruit("Grape", R.drawable.grape),
        Fruit("Pineapple", R.drawable.pineapple), Fruit("Strawberry", R.drawable.strawberry),
        Fruit("Cherry", R.drawable.cherry), Fruit("Mango", R.drawable.mango)
    )
    private val fruitList = mutableListOf<Fruit>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
            setContentView(binding.root)
            
        ...
         
        
        initFruits()
        val layoutManager = GridLayoutManager(this, 2)
        binding.recyclerView.layoutManager = layoutManager
        
        // 直接实例化 Adapter
        adapter = FruitAdapter()
        binding.recyclerView.adapter = adapter
        
        // 第一次提交数据
        adapter.submitList(fruitList)
    }

    private fun initFruits() {
        fruitList.clear()
        repeat(50) {
            val index = (0 until fruits.size).random()
            fruitList.add(fruits[index])
        }
    }

    ...
    
}

导入了 Glide 的依赖,很有可能会出现依赖项冲突的问题。我们可以将项目迁移到 AndroidX,并让 Gradle 自动处理这种冲突。

gradle.properties 文件中,确保以下两行配置存在且值为 true,然后同步项目即可。

# 让 Android 插件在构建时使用 AndroidX 库替代旧的 Support Library
android.useAndroidX=true
# 启用 Jetifier,自动将依赖的第三方库中的旧 Support Library 引用迁移到对应的 AndroidX 引用 
android.enableJetifier=true

运行程序,界面如下:

image.png

可以看到水果图片使用了卡片来展示,拥有圆角和阴影。

AppBarLayout

但你会发现 RecyclerView 完全挡住了我们的 Toolbar,这是因为 CoordinatorLayout 布局会默认将子控件叠放在布局左上角。

那怎么办呢?

你可以手动给 RecyclerView 设置一个偏移量来解决这个问题,但我们现在使用了 CoordinatorLayout,有更好的解决方案,那就是 AppBarLayout

实际上是一个响应滚动的垂直 LinearLayout,它能够与 CoordinatorLayout 中的其他可滚动视图(如 RecyclerView)协作。

我们让 AppBarLayout 包裹 Toolbar 控件,并指定 RecyclerView 的布局行为(Behavior)。activity_main.xml 中的部分代码如下:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.MaterialComponents.Light" />
    </com.google.android.material.appbar.AppBarLayout>


    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    ...

</androidx.coordinatorlayout.widget.CoordinatorLayout>

运行程序,界面效果如图:

image.png

它是怎么解决的呢?

关键在于 app:layout_behavior="@string/appbar_scrolling_view_behavior" 这行代码, RecyclerView 的这个行为会监听 AppBarLayout 的位置和大小,自动为 RecyclerView 设置顶部偏移量,从而保证它始终在 AppBarLayout 的下方,解决了遮挡问题。

实际上,当 RecyclerView 列表滚动时,CoordinatorLayout 会通知给 AppBarLayout。只是我们并没有指定其内部子控件的滚动行为。

你可以通过其 app:layout_scrollFlags 属性来指定:

<androidx.appcompat.widget.Toolbar
    ...
    app:layout_scrollFlags="scroll|enterAlways|snap" />

其中:

  • scroll: 表示当 RecyclerView 向上滚动的时候,Toolbar 会跟着向上滚动从而滚出屏幕;

  • enterAlways: 表示当 RecyclerView 向下滚动的时候,Toolbar 会跟着向下滚动从而重新进入屏幕;

  • snap: 表示当 RecyclerView 滚动停止时,如果 Toolbar 还没有完全隐藏或显示时,会根据当前滚动的距离,自动选择是完全隐藏还是完全显示的状态,不会悬停到中间。

再次运行程序,并向上滚动列表,界面如图所示:

image.png

可以看到,在我们向上滚动列表时,Toolbar 消失了。向下滚动时,Toolbar 会重新出现。

这是因为 Material Design 认为:当用户滚动列表时,注意力在列表内容上,如果此时 Toolbar 还占据着屏幕空间,会影响到用户的阅读体验,所以让 Toolbar 隐藏。而当用户需要操作 Toolbar 上的功能时,可以轻轻往下滑,来让 Toolbar 出现,不会影响到功能操作。

下拉刷新

最后,我们来添加下拉刷新的功能。SwipeRefreshLayout 是可用于实现下拉刷新的控件。我们把想要实现下拉刷新的控件放到 SwipeRefreshLayout 中,即可让这个控件支持下拉刷新。

activity_main.xml 中的部分代码如下:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    ...

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefresh"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

    ...

</androidx.coordinatorlayout.widget.CoordinatorLayout>

因为现在 SwipeRefreshLayout 才是 CoordinatorLayout 的直接子控件,所以布局行为(app:layout_behavior 属性)要声明在 SwipeRefreshLayout 上。

我们在 MainActivity 中设置下拉刷新的监听器并实现刷新逻辑。代码如下:

class MainActivity : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        
        // 设置下拉进度条的颜色
        binding.swipeRefresh.setColorSchemeResources(R.color.app_primary)
        // 注册下拉刷新监听器
        binding.swipeRefresh.setOnRefreshListener {
            refreshFruits(adapter)
        }
    }

    
    private fun refreshFruits(adapter: FruitAdapter) {
        // 开启一个协程来模拟网络请求
        lifecycleScope.launch {
            // 模拟2秒的延迟
            delay(2000)
            // 重新生成数据
            initFruits()
            // 提交新数据
            adapter.submitList(fruitList.toList())
            // 隐藏刷新进度条
            binding.swipeRefresh.isRefreshing = false
        }
    }

    ...
}

注意这里使用 submitList() 方法提交新数据时,需要传入新的列表实例,这样才能触发 DiffUtil。否则它会认为列表没有变化(引用相同),不进行比较,导致界面不刷新。

运行程序,往下滑动列表,界面效果如图:

image.png

松开手指,可以看到:

image.png

2 秒后,下拉刷新的进度条会消失,界面上的数据会更新。

未完待续...