Android RecyclerView从入门到精通

一、前言

    在以前的Android开发过程中,列表使用 ListView, 网格使用 GridView。随着Android不断的发展,官方推出了许多性能更优的控件, RecyclerView 就是其中之一。RecyclerViewListViewGridView 的更高级版本,不仅仅性能更优越,也更加的灵活。

二、RecyclerView 使用入门

2.1 添加支持库

     RecyclerView 属于 v7 支持库,要使用 RecyclerView 首先要添加 v7 支持库。添加支持库的如下。

dependencies {
    // 使用support支持库使用这个
    implementation 'com.android.support:recyclerview-v7:28.0.0'
    // 使用androidx支持库用这个
    implementation 'androidx.recyclerview:recyclerview:1.1.0'
}

注意事项:从 API 28 开始Google官方推荐使用Android Jetpack,包名以androidx开头居多,RecyclerView分为 support 支持库和 androidx 支持库,这两个只能选其一,不能同时存在,请跟项目实际情况选用。

2.2 将 RecyclerView 添加到布局

     引入依赖后,就可以在布局中添加 RecyclerView 了。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <!-- 注意:如果使用android support库,这里的类名请使用support库里的 -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintWidth_default="spread"
        app:layout_constraintHeight_default="spread"/>

</androidx.constraintlayout.widget.ConstraintLayout>

2.3 在代码中引用 RecyclerView 并配置

2.3.1 设置布局管理器

     现成的布局管理器有 LinearLayoutManager(线性)、StaggeredGridLayoutManager(错位网格)、GridLayoutManager(网格)等。

val recyclerView = findViewById<RecyclerView>(R.id.recyclerview).apply {
    //  设置布局管理器
    layoutManager = LinearLayoutManager(this@MainActivity)
}

2.3.2 设置列表适配器

     列表适配器会创建列表项的视图,并使用新数据替换不再可见的是图像。RecyclerView 的列表适配器必须扩展 RecyclerView.Adapter 类。

2.3.2.1 创建项目布局
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:textSize="18sp"
        android:textColor="#FF000000"
        android:maxLines="1"
        android:ellipsize="end"/>
    <TextView
        android:id="@+id/tvDesc"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@+id/tvTitle"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="5dp"
        android:textSize="14sp"
        android:textColor="#FF666666"
        android:maxLines="3"
        android:ellipsize="end"/>

</androidx.constraintlayout.widget.ConstraintLayout>
2.3.2.2 创建项目布局

     创建列表适配器必须包含列表项,用来展示列表项的内容,列表项必须扩展 RecyclerView.ViewHolder 类。

class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val title: TextView = itemView.findViewById(R.id.tvTitle)
    val desc: TextView = itemView.findViewById(R.id.tvDesc)
}
2.3.2.3 编写适配器类

     适配器类必须扩展 RecyclerView.Adapter 类,主要有三个方法需要重写,包括onCreateViewHolder(构建ViewHolder)、getItemCount(获取列表项目数量)、onBindViewHolder(显示列表项目视图),如下所示:

class MyListAdapter() : RecyclerView.Adapter<ItemViewHolder>() {

    val data: ArrayList<String> = ArrayList<String>()

    fun addData(d: ArrayList<String>) {
        if(d.isNotEmpty()) {
            data.addAll(d)
        }
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        // 创建ViewHolder对象
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_list_1, parent, false)

        return ItemViewHolder(itemView)
    }

    override fun getItemCount(): Int {
        // 获取项目的数量
        return data.size
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        // 绑定ViewHolder,这里设置需要展示的数据
        holder.title.text = "Item $position"
        holder.desc.text = data[position]
    }

}
2.3.2.4 为 RecyclerView 设置列表适配器
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview).apply {
    // 设置布局管理器
    layoutManager = LinearLayoutManager(this@MainActivity)
    // 设置适配器
    adapter = MyListAdapter().apply {
        // 添加数据
        val list = ArrayList<String>().apply {
            for (i in 0 .. 20) {
                add("This is content of $i")
            }
        }
        addData(list)
    }
}

     经过以上的步骤,你已成功入门了 RecyclerView,下面是效果图:
RecyclerView入门效果图

    以上是 LinearLayoutManager 的效果,通过设置属性可以实现一些独特的效果,比如横向的列表。另外,只需要换一种布局管理,就可以实现不一样的布局样式,比如网格布局,这个可以自行尝试。

三、RecyclerView 进阶

     经过前面内容的学习,基本的 RecyclerView 的入门就完成了。接下来可以更加深入学习。

3.1 列表分割线

     上面的例子的样式中,项目之间缺少了分割线,看起来有点凌乱,我们都知道 ListView 可以直接在布局声明中添加分割线,但是 RecyclerView 没有这个功能。要添加列表项目间的分割线,该如何实现呢?

3.1.1 在列表项目布局中添加分割线

     一种最简单的方式就是在列表项目的布局中添加(相信很多人在使用 ListView 的时候也这么干过)。在列表项目的布局中添加分割线,在线性列表布局中比较实用(横向或者纵向),如果在网格布局中,实现起来就非常的不协调了。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:textSize="18sp"
        android:textColor="#FF000000"
        android:maxLines="1"
        android:ellipsize="end"/>
    <TextView
        android:id="@+id/tvDesc"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@+id/tvTitle"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="5dp"
        android:textSize="14sp"
        android:textColor="#FF666666"
        android:maxLines="3"
        android:ellipsize="end"/>
    <View
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="#FF808080"
        app:layout_constraintTop_toBottomOf="@+id/tvDesc"/>

</androidx.constraintlayout.widget.ConstraintLayout>
  • 效果图
    在列表项目布局中添加分割线

3.1.2 使用 DividerItemDecoration 添加分割线

     使用 DividerItemDecoration 添加分割线,调用 RecyclerViewaddItemDecoration() 方法进行。

val recyclerView = findViewById<RecyclerView>(R.id.recyclerview).apply {
    layoutManager = LinearLayoutManager(this@MainActivity).apply {
        orientation = LinearLayoutManager.VERTICAL
    }
    adapter = MyListAdapter().apply {
        val list = ArrayList<String>().apply {
            for (i in 0 .. 20) {
                add("This is content of $i")
            }
        }
        addData(list)
    }
}

recyclerView.addItemDecoration(DividerItemDecoration(this@MainActivity, DividerItemDecoration.VERTICAL))
  • 效果
    DividerItemDecoration添加默认分割线

注意事项:DividerItemDecoration 定义时的方向是指分割线进行分割的方向,而不是分割线本身的方向。比如一个垂直的 RecyclerView 列表,添加的分割线是垂直方向分割列表项目,即 VERTICAL ,但是分割线的线条是横向摆放的,这是容易搞错的,大家在使用过程中需要多注意。

    上面的例子通过 DividerItemDecoration 添加的分割线是默认风格的分割线,如果需要自定义分割线的样式呢?其实这也很简单, DividerItemDecoration 包含了供开发者自定义样式的接口 setDrawable()。通过这个接口,开发者可以自定义自己的样式,比如具有渐变效果的分割线。

  • 定义一个渐变的 Drawable
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <size android:height="0.5dp" />
    <gradient android:angle="0"
        android:startColor="#FFFF0000"
        android:centerColor="#FF00FF00"
        android:endColor="#FF0000FF" />
</shape>
  • DividerItemDecoration 设置自定义的 Drawable
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview).apply {
    layoutManager = LinearLayoutManager(this@MainActivity).apply {
        orientation = LinearLayoutManager.VERTICAL
    }
    adapter = MyListAdapter().apply {
        val list = ArrayList<String>().apply {
            for (i in 0 .. 20) {
                add("This is content of $i")
            }
        }
        addData(list)
    }
}

recyclerView.addItemDecoration(DividerItemDecoration(this@MainActivity, DividerItemDecoration.VERTICAL).apply {
    setDrawable(resources.getDrawable(R.drawable.list_divider_drawable)!!)
})
  • 效果
    自定义样式的DividerItemDecoration

注意事项:DividerItemDecoration 并没有设定分割线高度的方法,所以给定的 Drawable 需要符合预期的 UI 设计,防止出现分割线大小失衡、被拉伸或者挤压的情形。

3.1.3 自定义列表分割线

    DividerItemDecorationRecyclerView 预定义的分割线类,如果无法满足需求,开发者还可以自己自定义分割线,自定义分割线需要继承自 RecyclerView.ItemDecoration 类(DividerItemDecoration 就是继承自该类)。自定义分割线需要覆盖实现以下借个方法:

  • getItemOffsets():获取给定项目的偏移量( 这是列表项目绘制的偏移量,按分割线的绘制要求设定项目绘制的偏移,实际上就是扩展列表项,这样就可以预留位置绘制分割线,防止分割线与列表项目重叠覆盖)。
  • onDraw():将所有有效的装饰物(Decoration)绘制到 RecyclerView 提供的画布中。
  • onDrawOver():将所有有效的装饰物(Decoration)绘制到 RecyclerView 提供的画布中。(PS:笔者看到官方文档描述跟 onDraw() 一致,实现起来的效果也一样,不知道有啥区别)
class MyDividerItemDecoration(context: Context, orientation: Int): RecyclerView.ItemDecoration() {

    companion object {
        const val HORIZONTAL = RecyclerView.HORIZONTAL
        const val VERTICAL = RecyclerView.VERTICAL
    }

    private val TAG = "DividerItem"
    private val ATTRS = intArrayOf(android.R.attr.listDivider)

    private var mDivider: Drawable? = null

    /**
     * Current orientation. Either [.HORIZONTAL] or [.VERTICAL].
     */
    private var mOrientation = 0

    private var mBounds = Rect()

    init {
        val a = context.obtainStyledAttributes(ATTRS)
        mDivider = a.getDrawable(0)
        if (mDivider == null) {
            Log.w(TAG,
                "@android:attr/listDivider was not set in the theme used for this "
                        + "DividerItemDecoration. Please set that attribute all call setDrawable()"
            )
        }
        a.recycle()
        setOrientation(orientation)
    }

    fun setOrientation(orientation: Int) {
        if(orientation != HORIZONTAL && orientation != VERTICAL) {
            throw IllegalArgumentException("Orientation value is invalid")
        }
        this.mOrientation = orientation
    }

    fun setDrawable(drawable: Drawable) {
        mDivider = drawable
    }

    /**
     * 这是列表项目绘制的偏移量,按分割线的绘制要求设定项目绘制的偏移量,这样就可以预留位置绘制分割线,防止分割线与列表项目重叠覆盖
     * @param outRect
     * @param view
     * @param state
     */
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        if (mDivider == null) {
            outRect.set(0, 0, 0, 0)
            return
        }
        if (mOrientation == DividerItemDecoration.VERTICAL) {
            // 绘制项目时,底部向下偏移绘制分割线高度的内容(也就是绘制项目的时候,底部多绘制分割线高度的空白部分,用来绘制分割线)
            outRect.set(0, 0, 0, mDivider!!.intrinsicHeight)
        } else {
            outRect.set(0, 0, mDivider!!.intrinsicWidth, 0)
        }
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        if (parent.layoutManager == null || mDivider == null) {
            return
        }
        if (mOrientation == DividerItemDecoration.VERTICAL) {
            drawVertical(c, parent)
        } else {
            drawHorizontal(c, parent)
        }
    }

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
            super.onDrawOver(c, parent, state)
    }

    /**
     * 绘制垂直方向的分割线(分割线是横向的)
     * @param canvas 画布
     * @param parent RecyclerView
     */
    private fun drawVertical(canvas: Canvas, parent: RecyclerView) {
        canvas.save()
        // 计算分割线绘制区域
        val left: Int
        val right: Int
        if (parent.clipToPadding) {
            left = parent.paddingLeft
            right = parent.width - parent.paddingRight
            canvas.clipRect(left, parent.paddingTop, right,
                parent.height - parent.paddingBottom)
        } else {
            left = 0
            right = parent.width
        }
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)
            parent.getDecoratedBoundsWithMargins(child, mBounds)
            val bottom = mBounds.bottom + child.translationY.roundToInt()
            val top = bottom - mDivider!!.intrinsicHeight
            mDivider!!.setBounds(left, top, right, bottom)
            mDivider!!.draw(canvas)
        }
        canvas.restore()
    }

    /**
     * 绘制水平方向的分割线(分割线是垂直的)
     * @param canvas 画布
     * @param parent RecyclerView
     */
    private fun drawHorizontal(canvas: Canvas, parent: RecyclerView ) {
        canvas.save()
        // 计算分割线绘制区域
        val top: Int
        val bottom: Int
        if (parent.clipToPadding) {
            top = parent.paddingTop
            bottom = parent.height - parent.paddingBottom
            canvas.clipRect(parent.paddingLeft, top,
                parent.width - parent.paddingRight, bottom)
        } else {
            top = 0
            bottom = parent.height
        }
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)
            parent.layoutManager!!.getDecoratedBoundsWithMargins(child, mBounds)
            val right = mBounds.right + child.translationX.roundToInt()
            val left = right - mDivider!!.intrinsicWidth
            mDivider!!.setBounds(left, top, right, bottom)
            mDivider!!.draw(canvas)
        }
        canvas.restore()
    }
}
  • 效果
    自定义分割线

说明:

  1. 以上的例子中,分割线的高度(横向分割线的宽度),都是根据分割线 Drawable 来自行调整的,如果想要固定,可以在绘制分割线时指定分割线大小,但是要记住同时在计算偏移量的时候也是用固定值。
  2. getItemOffsets() 是列表项的偏移量,就是对列表项进行扩展,换句话说就是列表项比原来更高或者更宽了,扩展出来的位置用来绘制分割线。但是如果扩展受到限制(例如:屏幕限制),则会通过压缩内容来达到效果(如下图:左右扩展,但是因屏幕限制无法扩展,只能通过缩小内容区域,在两边预留需要的位置)。
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
    if (mDivider == null) {
        outRect.set(0, 0, 0, 0)
        return
    }
    if (mOrientation == DividerItemDecoration.VERTICAL) {
        // 绘制项目时,底部向下偏移绘制分割线高度的内容(也就是绘制项目的时候,底部多绘制分割线高度的空白部分,用来绘制分割线)
        outRect.set(100, 0, 100, mDivider!!.intrinsicHeight)
    } else {
        outRect.set(0, 0, mDivider!!.intrinsicWidth, 0)
    }
}

空间扩展受限-压缩内容

3.1.4 自定义任何的装饰物

    以上介绍了通过继承 RecyclerView.ItemDecoration 类实现分割线,Decoration 的含义就是“装饰物”,通过实现这个类,可以往列表项目中添加任何的装饰物。上面的例子稍微调整一下

class MyDividerItemDecoration(context: Context, orientation: Int): RecyclerView.ItemDecoration() {

    companion object {
        const val HORIZONTAL = RecyclerView.HORIZONTAL
        const val VERTICAL = RecyclerView.VERTICAL
    }

    private val TAG = "DividerItem"
    private val ATTRS = intArrayOf(android.R.attr.listDivider)

    private var mDivider: Drawable? = null

    /**
     * Current orientation. Either [.HORIZONTAL] or [.VERTICAL].
     */
    private var mOrientation = 0

    private var mBounds = Rect()

    init {
        val a = context.obtainStyledAttributes(ATTRS)
        mDivider = a.getDrawable(0)
        if (mDivider == null) {
            Log.w(TAG,
                "@android:attr/listDivider was not set in the theme used for this "
                        + "DividerItemDecoration. Please set that attribute all call setDrawable()"
            )
        }
        a.recycle()
        setOrientation(orientation)
    }

    fun setOrientation(orientation: Int) {
        if(orientation != HORIZONTAL && orientation != VERTICAL) {
            throw IllegalArgumentException("Orientation value is invalid")
        }
        this.mOrientation = orientation
    }

    fun setDrawable(drawable: Drawable) {
        mDivider = drawable
    }

    /**
     * 这是列表项目绘制的偏移量,按分割线的绘制要求设定项目绘制的偏移量,这样就可以预留位置绘制分割线,防止分割线与列表项目重叠覆盖
     * @param outRect
     * @param view
     * @param state
     */
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        if (mDivider == null) {
            outRect.set(0, 0, 0, 0)
            return
        }
        if (mOrientation == DividerItemDecoration.VERTICAL) {
            // 绘制项目时,底部向下偏移绘制分割线高度的内容(也就是绘制项目的时候,底部多绘制分割线高度的空白部分,用来绘制分割线)
            outRect.set(20, 0, 0, mDivider!!.intrinsicHeight)
        } else {
            outRect.set(0, 20, mDivider!!.intrinsicWidth, 0)
        }
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        if (parent.layoutManager == null || mDivider == null) {
            return
        }
        if (mOrientation == DividerItemDecoration.VERTICAL) {
            drawVertical(c, parent)
        } else {
            drawHorizontal(c, parent)
        }
    }

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
    }

    /**
     * 绘制垂直方向的分割线(分割线是横向的)
     * @param canvas 画布
     * @param parent RecyclerView
     */
    private fun drawVertical(canvas: Canvas, parent: RecyclerView) {
        canvas.save()
        // 计算分割线绘制区域
        val left: Int
        val right: Int
        if (parent.clipToPadding) {
            left = parent.paddingLeft
            right = parent.width - parent.paddingRight
            canvas.clipRect(left, parent.paddingTop, right,
                parent.height - parent.paddingBottom)
        } else {
            left = 0
            right = parent.width
        }
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)
            parent.getDecoratedBoundsWithMargins(child, mBounds)
            val bottom = mBounds.bottom + child.translationY.roundToInt()
            val top = bottom - mDivider!!.intrinsicHeight
            mDivider!!.setBounds(left, top, right, bottom)
            mDivider!!.draw(canvas)

            // 绘制左边标志装饰物
            ColorDrawable().apply {
                setBounds(left, child.top, 20, child.bottom)
                color = if(i % 2 == 0) {
                    Color.RED
                } else {
                    Color.CYAN
                }
                draw(canvas)
            }

        }
        canvas.restore()
    }

    /**
     * 绘制水平方向的分割线(分割线是垂直的)
     * @param canvas 画布
     * @param parent RecyclerView
     */
    private fun drawHorizontal(canvas: Canvas, parent: RecyclerView ) {
        canvas.save()
        val top: Int
        val bottom: Int
        if (parent.clipToPadding) {
            top = parent.paddingTop
            bottom = parent.height - parent.paddingBottom
            canvas.clipRect(parent.paddingLeft, top,
                parent.width - parent.paddingRight, bottom)
        } else {
            top = 0
            bottom = parent.height
        }
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)
            parent.layoutManager!!.getDecoratedBoundsWithMargins(child, mBounds)
            val right = mBounds.right + child.translationX.roundToInt()
            val left = right - mDivider!!.intrinsicWidth
            mDivider!!.setBounds(left, top, right, bottom)
            mDivider!!.draw(canvas)

            // 绘制上边标志装饰物
            ColorDrawable().apply {
                setBounds(left, child.top, child.right, 20)
                color = if(i % 2 == 0) {
                    Color.RED
                } else {
                    Color.CYAN
                }
                draw(canvas)
            }
        }
        canvas.restore()
    }
}
  • 效果
    装饰物

3.2 点击效果

    对于列表,如果有点击效果(Selector),用户体验上视觉效果更好,在 ListView 上面,可以直接在 XML 定义中添加点击效果,在 RecylerView 上面没有这个设置。

3.2.1 在列表项目布局中添加点击效果

    这种方式实现起来也比较简单,定义一个 Selector 资源,然后在列表项布局中设置 background 就可以了。

  • 定义 Selector
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:state_pressed="true"
        android:drawable="@android:color/darker_gray" />
    <item android:state_selected="true"
        android:drawable="@android:color/holo_blue_bright" />
    <item android:drawable="@android:color/transparent" />
</selector>
  • 设置列表项背景
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:clickable="true"
    android:background="@drawable/item_selector"
    android:focusable="true">
    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:textSize="18sp"
        android:textColor="#FF000000"
        android:maxLines="1"
        android:ellipsize="end"/>
    <TextView
        android:id="@+id/tvDesc"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@+id/tvTitle"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="5dp"
        android:textSize="14sp"
        android:textColor="#FF666666"
        android:maxLines="3"
        android:ellipsize="end"/>
</androidx.constraintlayout.widget.ConstraintLayout>
  • 效果
    RecyclerView列表项点击效果

注意事项:列表项布局必须添加 android:clickable="true",否则点击效果无法体现。

3.2.2 在 Android 5.0 以上实现水波纹点击效果

    在res 目录下新增一个 drawable-v21 目录,在里面新建 XML 资源文件,资源文件根节点为 ripple

  • 示例
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@android:color/darker_gray">
    <item android:drawable="@android:color/white" />
</ripple>

说明:

  1. <ripple> 节点的 android:color 为波纹效果的颜色;<item>节点的 android:drawable 为背景。
  2. 如果您的项目 miniSdkVersion 不低于 21,可以直接将资源文件放在 res/drawable 目录下,而不需要新建 res/drawble-v21 目录。

3.3 RecyclerView 添加列表点击事件

    列表展示内容,点击事件是少不了的,对于 RecyclerView 而言,没有像 ListView 那样的 setOnItemClickListener,但是可以使用自己的方式实现点击事件。

3.3.1 在适配器中 onBindViewHolder 添加点击事件

    最简单的方式就是在适配器的 onBindViewHolder 方法中,给绑定的 ItemView 添加点击事件。

  • 定义一个接口
interface OnItemClickListener {
    abstract fun onItemClick(holder: ItemViewHolder, position: Int)
}
  • 在适配器类的 onBindViewHolder 方法中添加点击事件处理
class MyListAdapter() : RecyclerView.Adapter<ItemViewHolder>() {

    val data: ArrayList<String> = ArrayList<String>()

    var onItemClickListener: OnItemClickListener? = null
        get() = field
        set(value) {
            field = value
        }

    fun addData(d: ArrayList<String>) {
        if(d.isNotEmpty()) {
            data.addAll(d)
        }
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        // 创建ViewHolder对象
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_list_1, parent, false)
        return ItemViewHolder(itemView)
    }

    override fun getItemCount(): Int {
        // 获取项目的数量
        return data.size
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        // 绑定ViewHolder,这里设置需要展示的数据
        holder.title.text = "Item $position"
        holder.desc.text = data[position]

        holder.itemView.setOnClickListener {
            onItemClickListener?.onItemClick(holder, position)
        }
    }

}
  • 添加点击响应处理
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview).apply {
    layoutManager = LinearLayoutManager(this@MainActivity).apply {
        orientation = LinearLayoutManager.VERTICAL
    }
    adapter = MyListAdapter().apply {
        val list = ArrayList<String>().apply {
            for (i in 0 .. 20) {
                add("This is content of $i")
            }
        }
        addData(list)
        onItemClickListener = object: OnItemClickListener {
            override fun onItemClick(holder: ItemViewHolder, position: Int) {
                Toast.makeText(this@MainActivity, "Item $position clicked", Toast.LENGTH_SHORT).show()
            }
        }
    }
}
  • 效果
    RecyclerView点击效果

注意事项:在列表项视图绑定时添加点击事件的方式,可以简单实现点击事件,但是如果要同时实现长按事件,会有冲突,因为onClick 是在触摸事件 ACTION_UP 回调,但是 onLongClick 是在触摸事件 ACTION_DOWN 之后,长时间没有 ACTION_UP 的时候相应,因此在执行 ACTION_UP 的时候,依旧会回调 onClick,其实处理起来也很简单,只需要在 onLongClick 回调中,返回值为 true 即可(意思是事件在此处已处理完毕,不再往下传递)。

3.3.2 使用 RecyclerView.OnItemTouchListener 实现点击事件

    通过 RecyclerViewaddOnItemTouchListener 添加 RecyclerView 的点击事件,但是点击事件相应是整个 RecyclerView,需要通过点击事件的坐标通过 findChildViewUnder() 寻找到对应的子 View,然后使用 getChildAdapterPosition() 获取点击所在的列表项目位置(position)。

val child = recyclerView.findChildViewUnder(e.x, e.y)

if(null != child) {
    val position = recyclerView.getChildAdapterPosition(child)
    Toast.makeText(this@MainActivity, "Item $position was long clicked", Toast.LENGTH_SHORT).show()
}

    可以通过 GestureDetectorCompat 来解析 RecyclerView.OnItemTouchListener 监听到的事件,直接获取是点击事件还是长按事件,减少工作量。

gestureDetector = GestureDetectorCompat(this@MainActivity, object : GestureDetector.SimpleOnGestureListener() {
    override fun onSingleTapUp(e: MotionEvent?): Boolean {

        e?.also {
            val child = recyclerView.findChildViewUnder(e.x, e.y)

            if(null != child) {
                val position = recyclerView.getChildAdapterPosition(child)
                Toast.makeText(this@MainActivity, "Item $position was clicked", Toast.LENGTH_SHORT).show()
            }
        }
        return super.onSingleTapUp(e)
    }

    override fun onLongPress(e: MotionEvent?) {
        e?.also {
            val child = recyclerView.findChildViewUnder(e.x, e.y)

            if(null != child) {
                val position = recyclerView.getChildAdapterPosition(child)
                Toast.makeText(this@MainActivity, "Item $position was long clicked", Toast.LENGTH_SHORT).show()
            }
        }
        super.onLongPress(e)
    }

})
  • 完整代码
recyclerView.addOnItemTouchListener(object: RecyclerView.OnItemTouchListener {

    var gestureDetector: GestureDetectorCompat

    init {
        // 定义GestureDetectorCompat对象,快速解析触摸事件,分发为onClick和onLongClick
        gestureDetector = GestureDetectorCompat(this@MainActivity, object : GestureDetector.SimpleOnGestureListener() {
            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                // 处理点击事件
                e?.also {
                    val child = recyclerView.findChildViewUnder(e.x, e.y)

                    if(null != child) {
                        val position = recyclerView.getChildAdapterPosition(child)
                        Toast.makeText(this@MainActivity, "Item $position was clicked", Toast.LENGTH_SHORT).show()
                    }
                }
                return super.onSingleTapUp(e)
            }

            override fun onLongPress(e: MotionEvent?) {
                // 处理长按事件
                e?.also {
                    val child = recyclerView.findChildViewUnder(e.x, e.y)

                    if(null != child) {
                        val position = recyclerView.getChildAdapterPosition(child)
                        Toast.makeText(this@MainActivity, "Item $position was long clicked", Toast.LENGTH_SHORT).show()
                    }
                }
                super.onLongPress(e)
            }

        })
    }

    override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
    }

    override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {

        // 调用GestureDetectorCompat对象处理分发事件
        gestureDetector.onTouchEvent(e)

        // 此处不要返回true,否则点击效果将会失效
        return false
    }

    override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
    }

})

注意事项:通过RecyclerView.OnItemTouchListener 监听处理触摸事件的方式实现点击,请勿在 onInterceptTouchEvent() 返回 true,否则将会拦截点击事件,在列表项布局中添加的列表的点击效果将会失效。

3.4 RecyclerView 实现列表项选择

    RecyclerView 不像 ListView 可以直接实现列表项选择,需要借助 recyclerview-selection 库,大致的思路是:

  1. 为列表项添加支持选择状态的背景资源(或者可以显示选中状态的其他标记,例如:RadioButton);
  2. 构建一个 SelectionTracker 对象;
  3. 在适配器的 onBindViewHolder() 回调中根据 SelectionTracker 对象记录的项目选择状态并更新显示;

注意事项:实现列表项的选择,如果使用背景资源标识,必须支持选中状态的效果(recyclerview-selection 需要 state_activated 状态),否则选中后无法识别,当然,你可以在列表项中添加 RadioButtonCheckBox 之类的控件,用来标识选中状态。

3.4.1 引入 recyclerview-selection

  • 首先,需要在项目中引入 recyclerview-selection 库:
implementation 'androidx.recyclerview:recyclerview-selection:1.0.0'

3.4.2 构建 SelectionTracker 对象

    构建 SelectionTracker 对象,需要准备一些必要的东西:

  • RecyclerView 实例;
  • RecyclerView.Adapter 实例,并设置为 RecyclerView 实例的适配器;
  • 用来确定选项 Key 类型的 ItemKeyProvider 实例;
  • 用来查询项目详情的 ItemDetailsLookup 实例;
  • 用来确定选择状态存储策略的 StorageStrategy 实例。

    对于前两项,前面已经介绍过,这里就不再重复。接下来主要详细讲解下另外三个类型的实例对象。

3.4.2.1 确定选项 Key 类型的 ItemKeyProvider 实例

    对于列表选择,首先要确定用来标识列表项目的 Key 类型,Key 标识必须唯一,支持的类型有 LongStringParcelable。确定了使用哪种类型之后,构建 ItemKeyProvider 实例。

recyclerview-selection 库中自带 Long 类型的 Key 的 StableIdKeyProvider,但是需要注意的是,RecyclerView 在默认情况下,适配器中通过 getItemId() 返回的 ID 不是稳定的,所以需要在适配器中使用 setHasStableIds(true) 设定 ID 为稳定的,这样就会使得 ID 和列表的 position 进行绑定,变得稳定。

// 定义 Long 类型的 Key,StableIdKeyProvider
val itemLongKeyProvider = StableIdKeyProvider(recyclerView)

// 修改适配器代码,设置为稳定 ID
class MyListAdapter() : RecyclerView.Adapter<ItemViewHolder>() {
    // ........ 此处省略代码
    init {
        // 设置为稳定 ID
        setHasStableIds(true)
    }
    // ........ 此处省略代码
    override fun getItemId(position: Int): Long {
        // 稳定 ID,与 position 进行绑定。
        return position.toLong()
    }
}

    StableIdKeyProvider可以满足大多数需求,若无法满足,也可以选择适合自己的 Key 类型。自定义 Key 类型需要扩展 ItemKeyProvider 类,并重写 getKey()getPosition()两个方法。

  • getKey():根据项目位置,返回对应的 Key
  • getPosition(): 根据 Key,获取项目所在的位置
class ItemStringKeyProvider(var adapter: SelectionAdapter): ItemKeyProvider<String>(ItemKeyProvider.SCOPE_MAPPED) {

    override fun getKey(position: Int): String? {
        return adapter.data[position]
    }

    override fun getPosition(key: String): Int {
        return adapter.data.indexOf(key)
    }
}

说明:以上是 String 类型的 ItemKeyProvider 示例,必须注意的是,必须保证 Key 的唯一性。

3.4.2.2 查询项目详情的 ItemDetailsLookup 实例

    ItemDetailsLookup 实例用来查询项目的详情,获得 ItemDetails 实例, ItemDetails 对象包含两个必须实现的方法,getPosition()getSelectionKey(),分别是用来获取项目的位置和 Key。

val itemDetailsLookup = object : ItemDetailsLookup<Long>() {
    override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? {
        // 根据触摸事件获取点击的View
        val view = recyclerView.findChildViewUnder(e.x, e.y)
        if (view != null) {
            // 根据View获取ViewHolder对象
            val itemViewHolder = recyclerView.getChildViewHolder(view)
            return object : ItemDetails<Long>() {
                override fun getPosition(): Int = itemViewHolder.adapterPosition

                override fun getSelectionKey(): Long? = itemViewHolder.itemId
            }
        }
        return null
    }
}
3.4.2.3 确定选择状态存储策略的 StorageStrategy 实例

    recyclerview-selection 需要将存储状态进行存储,在UI重构时选择状态不丢失(例如:屏幕旋转),针对 Key 类型的不同,库提供了三个对应的存储策略,分别是 LongStorageStrategyStringStorageStrategyParcelableStorageStrategy。本文 Key 为 Long 类型,所以选择 LongStorageStrategy

val longStorageStrategy = StorageStrategy.createLongStorage();
3.4.2.4 构建 SelectionTracker 对象

    所有需要的参数都准备好了,下一步就是构建 SelectionTracker 对象。

var selectionTracker = SelectionTracker.Builder<Long>("selection_id",
    recyclerView, StableIdKeyProvider(recyclerView),
    itemDetailsLookup, longStorageStrategy)
    .withSelectionPredicate(SelectionPredicates.createSelectAnything()) // 设置选择模式,单选/多选
    .build()

说明:在构建 SelectionTracker 时可设置其他额外的属性,比如选择模式 SelectionPredicates,可设置单选(SelectionPredicates.createSelectSingleAnything) /多选(SelectionPredicates.createSelectAnything())模式,更多的属性设置可以参考官方Doc文档 SelectionTracker.Builder.

注意事项:在构建 SelectionTracker 时所传入的 RecyclerView 对象必须是已经设置了 RecyclerView.Adapter 的,否则将抛出 IllegalArgumentException 异常

3.4.3 根据 SelectionTracker 的选择状态信息更新UI

    经过前面的步骤, SelectionTracker 构建完成并且与 RrecyclerView 进行了关联,但是选择状态并不会自动在UI呈现出来,而是需要在 RecyclerView.Adapter 中的 onBindViewHolder() 方法中,对UI进行跟新显示。

class SelectionAdapter() : RecyclerView.Adapter<ItemViewHolder>() {

    val data = ArrayList<String>()
    // 定义SelectionTracker参数
    var tracker: SelectionTracker<Long>? = null

    init {
        setHasStableIds(true)
    }

    fun addData(d: ArrayList<String>) {
        if(d.isNotEmpty()) {
            data.addAll(d)
        }
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        // 创建ViewHolder对象
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_list_1, parent, false)

        return ItemViewHolder(itemView)
    }

    override fun getItemCount(): Int {
        // 获取项目的数量
        return data.size
    }

    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        // 绑定ViewHolder,这里设置需要展示的数据
        holder.title.text = data[position]
        holder.desc.apply {
            visibility = View.VISIBLE
            text = "This is item $position"
        }

        // 根据SelectionTracker记录的选择状态,更新UI显示
        tracker?.let {
            // 如果使用其他标记是否选中(如:RadioButton),可在这里更改控件的状态
//            holder.check.isChecked = it.isSelected(position.toLong())
            holder.itemView.isActivated = it.isSelected(position.toLong())
        }
    }

}

注意事项:由于在构建 SelectionTracker 时所传入的 RecyclerView 对象必须是已经设置了 RecyclerView.Adapter ,所以 RecyclerView.Adapter 内部的 SelectionTracker 对象赋值不能在适配器类的构造函数中传入(从逻辑上已经冲突了)。

  • 完整代码
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview_selection).apply {
    layoutManager = LinearLayoutManager(this@SelectionActivity).apply {
        orientation = LinearLayoutManager.VERTICAL
    }

    addItemDecoration(MyDividerItemDecoration(this@SelectionActivity, MyDividerItemDecoration.VERTICAL).apply {
        setDrawable(resources.getDrawable(R.drawable.list_divider_drawable)!!)
    })
}

val rcAdapter = SelectionAdapter().apply {
    val data = ArrayList<String>()
    for (i in 0 until 20) {
        data.add("Item $i")
    }
    addData(data)
}

// 设置适配器
recyclerView.adapter = rcAdapter

// 定义项目详情查询器
val itemDetailsLookup = object : ItemDetailsLookup<Long>() {
    override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? {
        // 根据触摸事件获取点击的View
        val view = recyclerView.findChildViewUnder(e.x, e.y)
        if (view != null) {
            // 根据View获取ViewHolder对象
            val itemViewHolder = recyclerView.getChildViewHolder(view)
            return object : ItemDetails<Long>() {
                override fun getPosition(): Int = itemViewHolder.adapterPosition

                override fun getSelectionKey(): Long? = itemViewHolder.itemId
            }

        }
        return null
    }

}

// 定义选择状态存储策略
val longStorageStrategy = StorageStrategy.createLongStorage();

// 定义SelectionTracker(关联的RecyclerView 必须先设置Adapter)
var selectionTracker = SelectionTracker.Builder<Long>("selection_id",
    recyclerView, StableIdKeyProvider(recyclerView),
    itemDetailsLookup, longStorageStrategy)
    .withSelectionPredicate(SelectionPredicates.createSelectAnything()) // 设置选择模式,单选/多选
    .build()

// 设置SelectionTracker对象
rcAdapter.tracker = selectionTracker
  • 效果
    RecyclerView多选模式

注意事项:如果通过背景来显示选中状态,并且使用了Android 5.0 以上的系统波纹效果,那么必须让你定义的 ripple 支持选中效果显示。将内部点击效果声明的 <item> 使用 <selector> 实现即可(如下所示)。

<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@android:color/darker_gray">
    <item>
        <selector>
            <item android:state_activated="true"
                android:drawable="@android:color/holo_blue_bright" />
            <item android:drawable="@android:color/white" />
        </selector>
    </item>
</ripple>

3.4.4 添加选中状态变更的观察者

    如果需要实时观察列表中的选择状态变更,可以为 SelectionTracker 添加一个 SelectionTracker.SelectionObserver 观察者。

  • 示例代码
selectionTracker.addObserver(object: SelectionTracker.SelectionObserver<Long>() {
    override fun onItemStateChanged(key: Long, selected: Boolean) {
        super.onItemStateChanged(key, selected)
    }

    override fun onSelectionChanged() {
        super.onSelectionChanged()
        tvMsg.text = "Selected Count: ${selectionTracker.selection.size()}"
    }

    override fun onSelectionRefresh() {
        super.onSelectionRefresh()
    }

    override fun onSelectionRestored() {
        super.onSelectionRestored()
    }

})
  • 效果(实时显示选中项目数量)
    在这里插入图片描述

四、参考

[1] Github示例代码:RecyclerviewDemo

[2] RecyclerView Doc

[3] recyclerview-selection Doc

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值