前言
书接上回...
现在,应用的内容区域还存在着大片空白,我们来使用一些水果图片填充这块区域,构建一个列表界面。
在这个过程中,我们将学习卡片式布局,它可以让元素看起来像一张张卡片,拥有圆角和阴影。然后,了解如何结合 CoordinatorLayout 与 AppBarLayout 实现可滚动折叠的顶部工具栏。最后,为列表加入下拉刷新的功能。
卡片式布局
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.xml 的 CoordinatorLayout 中放置一个 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 来加载图片,而不是调用 ImageView 的 setImageResource() 方法来加载。
最后,来到 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
运行程序,界面如下:
可以看到水果图片使用了卡片来展示,拥有圆角和阴影。
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>
运行程序,界面效果如图:
它是怎么解决的呢?
关键在于 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还没有完全隐藏或显示时,会根据当前滚动的距离,自动选择是完全隐藏还是完全显示的状态,不会悬停到中间。
再次运行程序,并向上滚动列表,界面如图所示:
可以看到,在我们向上滚动列表时,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。否则它会认为列表没有变化(引用相同),不进行比较,导致界面不刷新。
运行程序,往下滑动列表,界面效果如图:
松开手指,可以看到:
2 秒后,下拉刷新的进度条会消失,界面上的数据会更新。
未完待续...