前言
ListView 是之前 Android 主要使用的列表控件,因为其简单性,目前还有很多程序在使用 ListView。不过我们需要借助一些手段提升它的运行效率,否则性能非常差。并且 ListView 的扩展性也不好,它只能实现纵向滚动,想要实现横向滚动的话,是做不到的。
因此,Android 提供了一个更为强大和灵活的滚动控件:RecyclerView。它可以轻松实现和 ListView 同样的纵向列表效果,并且在性能优化、布局灵活方面更好。官方也推荐使用 RecyclerView,现在,我们来学习它的用法。
准备工作: 创建名为 RecyclerViewTest
的 Empty Views Activity 项目。
基本用法
为了让 RecyclerView 控件能够兼容所有安卓版本并且独立更新,Google 把 RecyclerView 控件定义在了 AndroidX 库里面,所以,我们在使用它之前,得先添加它的依赖。
在 build.gradle.kts(:app)
配置文件中,添加如下内容:
kotlin
dependencies {
implementation("androidx.recyclerview:recyclerview:1.4.0")
// ... 其他依赖
}
接下来在 activity_main.xml
布局文件中添加 RecyclerView 控件:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
在上述代码中,我们为 RecyclerView 控件指定了id
,并且它的宽度和高度都设置为了 match_parent
,这样能占满父布局。并且由于 RecyclerView 并不内置在系统 SDK 中,它是AndroidX 库的组件,所以要使用其完整包名。
为了实现和之前 ListView 示例同样的效果,我们将 ListViewTest
项目中的图片资源、Fruit
实体类以及 fruit_item.xml
列表项布局文件一并拷贝到当前项目中。
接下来,为 RecyclerView 添加适配器。新建 FruitAdapter
Kotlin 类,让它继承自 RecyclerView.Adapter
,并且泛型指定为 FruitAdapter.ViewHolder
。其中 ViewHolder
是我们在 FruitAdapter
中自定义的内部类,代码如下所示:
kotlin
class FruitAdapter(private val fruitList: List<Fruit>) :
RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
// ViewHolder 持有列表项布局对应的 ItemFruitBinding 视图绑定类的实例
inner class ViewHolder(private val binding: FruitItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(fruit: Fruit) {
binding.fruitName.text = fruit.name
binding.fruitImage.setImageResource(fruit.imageId)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// 创建视图绑定对象
val binding = FruitItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
// 将绑定对象传递给 ViewHolder
return ViewHolder(binding)
}
override fun getItemCount(): Int {
return fruitList.size
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val currentFruit = fruitList[position]
// 自定义 bind 方法内部通过绑定对象直接访问视图实例,并设置数据
holder.bind(currentFruit)
}
}
其中 FruitItemBinding 是由 fruit_item.xml 生成的视图绑定类,具体可看官方文档
这是 RecyclerView 适配器的一种标准写法,比起 ListView 的适配器来说,逻辑更清晰。
首先我们定义了一个内部类 ViewHolder
,它需要继承自 RecyclerView.ViewHolder
类。然后在 ViewHolder
的主构造函数中,我们传入了当前列表项布局对应的视图绑定类实例(FruitItemBinding
),这样,ViewHolder
就能通过这个 FruitItemBinding
实例来直接访问列表项布局中的各个控件实例,并将列表项数据设置到这些控件中。
FruitAdapter
的主构造函数将要展示的数据源传递进来,由于 FruitAdapter
继承自 RecyclerView.Adapter
,所以我们必须重写 onCreateViewHolder()
、onBindViewHolder()
和 getItemCount()
这三个核心方法:
-
onCreateViewHolder()
方法是用于创建ViewHolder
实例的,这个方法会在 RecyclerView 需要创建新的ViewHolder
实例时被调用。我们在这个方法中加载了列表项的布局并创建FruitItemBinding
实例,然后创建了一个ViewHolder
实例并返回。 -
onBindViewHolder()
方法用于对列表项的数据进行赋值,这个方法会在每个列表项被滚动到屏幕内时被调用。在这里,我们根据position
获取当前列表项的Fruit
实例,然后调用自定义的ViewHolder.bind()
方法,将数据设置到列表项布局中的 ImageView 和 TextView 控件上。 -
getItemCount()
方法用于得到数据源中列表项的数量,这里直接返回fruitList
的长度。
适配器创建完成后,就可以在 MainActivity
中使用了。代码如下:
kotlin
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList<Fruit>()
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initFruits() // 初始化水果数据
// 创建 LinearLayoutManager 对象,用于指定 RecyclerView 的布局方式
val layoutManager = LinearLayoutManager(this)
// LinearLayoutManager 默认是垂直方向
binding.recyclerView.layoutManager = layoutManager
// 创建 FruitAdapter 适配器实例
val adapter = FruitAdapter(fruitList)
// 将适配器设置给 RecyclerView
binding.recyclerView.adapter = adapter
}
private fun initFruits() {
repeat(2) {
fruitList.add(Fruit("Apple", R.drawable.apple_pic))
fruitList.add(Fruit("Banana", R.drawable.banana_pic))
fruitList.add(Fruit("Orange", R.drawable.orange_pic))
fruitList.add(Fruit("Watermelon", R.drawable.watermelon_pic))
fruitList.add(Fruit("Pear", R.drawable.pear_pic))
fruitList.add(Fruit("Grape", R.drawable.grape_pic))
fruitList.add(Fruit("Pineapple", R.drawable.pineapple_pic))
fruitList.add(Fruit("Strawberry", R.drawable.strawberry_pic))
fruitList.add(Fruit("Cherry", R.drawable.cherry_pic))
fruitList.add(Fruit("Mango", R.drawable.mango_pic))
}
}
}
我们使用了 initFruits()
方法来初始化了一些水果数据。在 onCreate
方法中,首先创建了一个 LinearLayoutManager
对象,并将它设置到了 RecyclerView 实例当中。LayoutManager
负责列表项在 RecyclerView 中的排列方式,而 LinearLayoutManager
则是表示列表项以线性方向(水平或垂直)排列。 LinearLayoutManager 表示线性布局。
接下来创建了 FruitAdapter 适配器实例,并且也设置到了 RecyclerView 实例当中,这样 RecyclerView 就和数据源之间建立连接了,可以展示列表了。
运行结果:
可以看到,使用 RecyclerView 实现的垂直列表效果和 ListView 几乎一样。当然这只是 RecyclerView 的基本用法而已,它的强大之处远不止于此,我们再来看看 RecyclerView 还能实现哪些功能。
实现横向滚动和瀑布流布局
横向滚动
RecyclerView 能实现横向滚动吗?
答案是可以的,并且还很简单。
首先我们修改列表项的布局 fruit_item.xml
,将列表项的元素改为垂直排列,并为列表项设置一个固定的宽度,以适应横向滚动。
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/fruitImage"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp" />
<TextView
android:id="@+id/fruitName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp" />
</LinearLayout>
可以看到,我们将 LinearLayout 的方向改为了 vertical
,并且宽度设置为了 80dp
固定值,为了保证横向滚动时每个列表项的宽度一致。因为水果名称有长有短,所以不能使用 wrap_content
,这样会导致列表项有长有短,影响美观和滑动体验。
接下来在 MainActivity
中,修改 LinearLayoutManager
的方向:
kotlin
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList<Fruit>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initFruits() // 初始化水果数据
val layoutManager = LinearLayoutManager(this)
layoutManager.orientation = LinearLayoutManager.HORIZONTAL // 设置为水平排列
binding.recyclerView.layoutManager = layoutManager
val adapter = FruitAdapter(fruitList)
binding.recyclerView.adapter = adapter
}
...
}
我们调用了 LinearLayoutManager
的 setOrientation()
方法来修改布局的排列方向,由默认的纵向排列改为横向排列,这样 RecyclerView 就可以进行横向滚动了。
运行效果:
你可以用手指左右滑动来查看屏幕外的数据。
为什么 ListView 很难实现这种仅仅是改变了滚动方向的效果呢?这是因为 ListView 的布局排列由控件内部管理,是固定的。而 RecyclerView 将布局排列的工作交给了 LayoutManager,LayoutManager 制定了一套可扩展的布局排列接口,只要其实现类遵循接口规范进行实现,就能定制出不同排列方式的布局,从而实现高度的灵活性。
瀑布流布局
除了之前的 LinearLayoutManager
线性布局外,RecyclerView 还提供了 GridLayoutManager 和 StaggeredGridLayoutManager 这两种内置的布局排列方式。
GridLayoutManager 可以用于实现网格布局,而 StaggeredGridLayoutManager 可用于实现瀑布流布局(交错网格布局)。我们现在就来实现一个瀑布流布局吧。
首先,还是修改列表项的布局 fruit_item.xml
。
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:orientation="vertical">
<ImageView
android:id="@+id/fruitImage"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp" />
<TextView
android:id="@+id/fruitName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:layout_marginTop="10dp" />
</LinearLayout>
这里,我们将 LinearLayout 的宽度设为了 match_parent
,因为在瀑布流布局中,每个列表项的实际宽度会根据布局的列数动态变化,不是固定值。我们还将 TextView 的对齐属性改成了左对齐。
然后在 MainActivity
中修改LayoutManager
的类型:
kotlin
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList<Fruit>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initFruits() // 初始化水果数据
// 瀑布流布局排列方式
val layoutManager = StaggeredGridLayoutManager(
3, // 指定列数
StaggeredGridLayoutManager.VERTICAL // 指定排列方向为垂直
)
binding.recyclerView.layoutManager = layoutManager
val adapter = FruitAdapter(fruitList)
binding.recyclerView.adapter = adapter
}
private fun initFruits() {
repeat(2) {
fruitList.add(Fruit(getRandomLengthString("Apple"), R.drawable.apple_pic))
fruitList.add(Fruit(getRandomLengthString("Banana"), R.drawable.banana_pic))
fruitList.add(Fruit(getRandomLengthString("Orange"), R.drawable.orange_pic))
fruitList.add(Fruit(getRandomLengthString("Watermelon"), R.drawable.watermelon_pic))
fruitList.add(Fruit(getRandomLengthString("Pear"), R.drawable.pear_pic))
fruitList.add(Fruit(getRandomLengthString("Grape"), R.drawable.grape_pic))
fruitList.add(Fruit(getRandomLengthString("Pineapple"), R.drawable.pineapple_pic))
fruitList.add(Fruit(getRandomLengthString("Strawberry"), R.drawable.strawberry_pic))
fruitList.add(Fruit(getRandomLengthString("Cherry"), R.drawable.cherry_pic))
fruitList.add(Fruit(getRandomLengthString("Mango"), R.drawable.mango_pic))
}
}
// 获取随机重复次数的字符串,以模拟不同高度的文本内容
private fun getRandomLengthString(str: String): String {
val n = (1..20).random()
return StringBuilder().run {
repeat(n) {
append(str)
}
toString()
}
}
}
我们在 onCreate()
方法中创建了 StaggeredGridLayoutManager
实例,它的构造函数接收两个参数:
-
第一个参数用于指定布局的列数,这里为 3 列。
-
第二个参数用于指定布局的排列方向,这里让布局纵向(垂直)排列。
然后,把创建好的 StaggeredGridLayoutManager
实例设置到 RecyclerView 实例当中就可以了。
为了使瀑布流的效果更加明显,列表项的高度应该不同。所以我们通过 getRandomLengthString
方法来随机修改每个列表项中水果名称的长度,由于 TextView 的高度是 wrap_content
,这将导致列表项整体高度不同。
运行效果:
RecyclerView的点击事件
和 ListView 一样,RecyclerView 也可以响应点击事件,否则不就是花瓶了吗?
不过它没有像 ListView 提供类似于 setOnItemClickListener()
这样的方法来为每一个列表项统一注册点击监听器。我们只能手动给每一个列表项注册点击事件。
为什么其他地方 RecyclerView 处处优于 ListView,在点击事件上却反而不如 ListView 呢?
其实这并不是 RecyclerView 的设计缺陷,反而提高了灵活性。因为我们往往想要为列表项中的某个控件注册点击事件时,而 ListView 实现起来就非常麻烦了。所以 RecyclerView 干脆舍去了为每一个列表项统一注册点击的监听器,将所有的点击事件交由各个具体的 View 视图去处理,使我们可以精细地控制每个控件的行为。
我们在 FruitAdapter
的 onCreateViewHolder()
方法中为列表项的根视图或是其内部的其它视图注册点击事件:
kotlin
class FruitAdapter(private val fruitList: List<Fruit>) :
RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// 创建视图绑定对象
val binding = FruitItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
// 将绑定对象传递给 ViewHolder
val viewHolder = ViewHolder(binding)
// 为整个列表项(根视图)注册点击事件
binding.root.setOnClickListener {
// 获取 ViewHolder 在适配器中的位置
val position = viewHolder.bindingAdapterPosition
// 检查 position 是否有效
if (position != RecyclerView.NO_POSITION) {
val fruit = fruitList[position]
Toast.makeText(parent.context, "You clicked view: ${fruit.name}", Toast.LENGTH_SHORT).show()
}
}
// 为列表项中的 ImageView 注册点击事件
binding.fruitImage.setOnClickListener {
val position = viewHolder.bindingAdapterPosition // 获取 ViewHolder 在适配器中的位置
// 检查 position 是否有效
if (position != RecyclerView.NO_POSITION) {
val fruit = fruitList[position]
Toast.makeText(parent.context, "You clicked image: ${fruit.name}", Toast.LENGTH_SHORT).show()
}
}
return viewHolder
}
...
}
可以看到,在 onCreateViewHolder()
方法中,我们分别为列表项的最外层布局和水果图片 ImageView 都注册了注册点击事件。RecyclerView 可以轻松实现列表项中任意控件或布局的点击事件。
然后在点击事件的回调中,我们通过 viewHolder.bindingAdapterPosition
获取了用户当前点击的列表项在适配器中的位置,注意:使用position之前,要判断它是否等于RecyclerView.NO_POSITION
,避免列表项因被移除或是位置发生变化所导致的数组越界异常, 然后拿到对应的 Fruit 实例,使用 Toast 来显示提示信息。
重新运行程序,效果:
可以看到点击水果图片(如苹果)部分时,会显示"you clicked image AppleAppleAppleApple"的提示。点击了列表项中图片以外的区域(如樱桃的文字部分)时,会显示"you clicked image CherryCherryCherryCherryCherryCherryCherryCherry"的提示,因为点击事件会被列表项最外层布局捕获。