详解 RecyclerView:从基础到布局与点击事件

前言

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
    }

    ...
}

我们调用了 LinearLayoutManagersetOrientation() 方法来修改布局的排列方向,由默认的纵向排列改为横向排列,这样 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 视图去处理,使我们可以精细地控制每个控件的行为。

我们在 FruitAdapteronCreateViewHolder() 方法中为列表项的根视图或是其内部的其它视图注册点击事件:

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"的提示,因为点击事件会被列表项最外层布局捕获。

相关推荐
Harrison_zhu1 小时前
在Android13上添加系统服务的好用例子
android
CV资深专家6 小时前
在 Android 框架中,接口的可见性规则
android
daifgFuture10 小时前
Android 3D球形水平圆形旋转,旋转动态更换图片
android·3d
二流小码农11 小时前
鸿蒙开发:loading动画的几种实现方式
android·ios·harmonyos
爱吃西红柿!12 小时前
fastadmin fildList 动态下拉框默认选中
android·前端·javascript
悠哉清闲13 小时前
工厂模式与多态结合
android·java
大耳猫14 小时前
Android SharedFlow 详解
android·kotlin·sharedflow
火柴就是我14 小时前
升级 Android Studio 后报错 Error loading build artifacts from redirect.txt
android
androidwork15 小时前
掌握 MotionLayout:交互动画开发
android·kotlin·交互
奔跑吧 android15 小时前
【android bluetooth 协议分析 14】【HFP详解 1】【案例一: 手机侧显示来电,但车机侧没有显示来电: 讲解AT+CLCC命令】
android·hfp·aosp13·telecom·ag·hf·headsetclient