如何为RecyclerView封装一个通用的Adapter

前言

在 Android 5.0 之前,如果你需要展示一个可以滚动的列表,我们用的是 ListView 控件。Android 5.0 后,官方在 support-v7 包推出了一个新的控件:RecyclerView,用来替代 ListView,解决 ListView 的一些问题和缺陷。可以说,RecyclerView 是一个先进的、灵活的加强版 ListView

本文将使用 kotlin 作为开发语言展示示例代码,实现一个完整的通用的 RecyclerView.Adapter,并用它来实现一个类似苹果 AppStore 的典型布局(后续再加入一个类似淘宝主页的布局)。

appstore.gif

RecyclerView 的基础使用

如果你已熟悉使用 RecyclerView,可以跳过此节

要使用 RecyclerView,我们需要导入 support-v7 库:

  1. 打开 app module 的 build.gradle
  2. 添加库的依赖
    dependencies {
        implementation 'com.android.support:recyclerview-v7:28.0.0'
    }
    
  3. 在 layout 文件中引入(可以作为整个布局的父容器)
    <?xml version="1.0" encoding="utf-8"?>
    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    
  4. 新建一个 Adapter 类继承 RecyclerView.Adapter 并重写其以下方法
        fun onCreateViewHolder(parent: ViewGroup, type: Int): VH
        fun getItemCount(): Int
        fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int)
    
    这里我们把类命名为 RecyclerAdapter
    class RecyclerAdapter(private val dataSet: Array<String>) :
        RecyclerView.Adapter<RecyclerAdapter.ViewHolder>() {
    
        class ViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView)
    
        // 供LayoutManager调用,创建新的视图
        override fun onCreateViewHolder(parent: ViewGroup,
                                    viewType: Int): RecyclerAdapter.ViewHolder {
            val textView = LayoutInflater.from(parent.context)
                .inflate(R.layout.recycler_text_view, parent, false) as TextView
            return ViewHolder(textView)
        }
    
        // 供LayoutManager调用,绑定视图数据
        override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
            holder.textView.text = dataSet[position]
        }
    
        // 供LayoutManager调用,返回视图数量大小
        override fun getItemCount() = dataSet.size
    }
    
  5. LayoutManagerAdapterRecyclerView 关联起来
    class RecyclerActivity: Activity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.recycler_activity)
    
            val dataSet = arrayListOf("one", "two", "three")
            val layoutManager = LinearLayoutManager(this)
            val adapter = RecyclerAdapter(dataSet)
    
            recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
            recyclerView.layoutManager = linearLayoutManager
            recyclerView.adapter = adapter
        }
    }
    

通过以上5个步骤,我们用 RecyclerView 完成了最基本的布局和数据绑定

现在我们思考一个问题:在开发过程中,对于每个类似滚动列表或者网格甚至瀑布流的页面,我们是否都需要对每个页面都创建一个属于该页面的 Adapter,然后重写第4步中的每一个方法?

答案很明显是否定的

接下来我们来看看如何编写一个通用的 Adapter,以便让它胜任各类复杂布局,以避免我们每开发一个页面都创建一个属于该页面的 Adapter 的情况

一步步实现 "万能的" Adapter

要胜任各种布局,就要从 Adapter 中有关视图的方法中寻找解决方法:

// onCreateViewHolder 方法通过 viewType 创建视图
fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerAdapter.ViewHolder

onCreateViewHolder 第二个参数是 viewType,也就是视图的类型。可以看出,通过这个参数,我们可以只在一个 Adapter 中便创建出 Int.MAX_VALUE 也就是 2147483647 种类型的视图,我们的万能 Adapter 就从这里开始突破

RecyclerView.Adapter 中还有一个方法:

// 根据 position 返回 viewType
fun getItemViewType(position: Int): Int

所有关键点就在 viewType 这里!因此我们的 ReclclerAdapter 必须重写 getItemViewType 方法,用于返回我们想要的 viewType 给视图创建者 LayoutManager

在这里我们定义一个通用的 ViewModel 类,用这个类的 layout 属性来保存视图模型中每一条数据的视图类型 viewType,我们还需要为每一个 ViewModel 标识它的 spanSize,以便我们使用 GridLayoutManager(或者其它 LayoutManager) 时决定这个视图所占的行数或者列数,最后用类型为 Any 的属性 value 来保存任意的实体数据:

/*
 * ViewModel
 * RecyclerAdapter子类
 * @param layout: 就是我们的viewType
 * @param spanSize: 当使用GridLayoutManager时View占据的列数(水平布局时为行数)
 * @param value: 保存各类实体数据
 */
data class ViewModel(
    val layout: Int,
    val spanSize: Int,
    val value: Any)

于是我们的 RecyclerAdapter 有如下代码:

// RecyclerAdapter
...
private val models = ArrayList<ViewModel>()

override fun getItemViewType(position: Int): Int {
    return models[position].layout
}

override fun onCreateViewHolder(parent: ViewGroup, type: Int)
    : RecyclerView.ViewHolder {
    // type is layout
    // see fun getItemViewType
    val view = LayoutInflater.from(parent.context).inflate(type, parent, false)
    return ViewHolder(view)
}

/*
 * 当使用GridLayoutManager时,我们可以这样:
 * layoutManager.spanSizeLookup = adapter.getSpanSizeLookup()
 */
fun getSpanSizeLookup(): GridLayoutManager.SpanSizeLookup {
    return object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
            // empty spanCount must equal to GridLayoutManager's spanCount
            return models[position].spanSize
        }
    }
}

class ViewHolder(val view: View): RecyclerView.ViewHolder(view)
...

也就是说,我们平时编写的 layout 文件夹中的 xml 文件就是 viewType

于是,我们就可以用下面示例代码实现混合布局(3种布局),用 spanSize 来决定行数或者列数:

// Some Activity or Fragment
    ...
    val models = arrayListOf(
        RecyclerAdapter.ViewModel(
            R.layout.layout_1,
            2,
            "I'm Layout 1"
        )
        RecyclerAdapter.ViewModel(
            R.layout.layout_2,
            1,
            "I'm Layout 2"
        )
        RecyclerAdapter.ViewModel(
            R.layout.layout_3,
            1,
            "I'm Layout 3"
        )
    )

    val adapter = RecyclerAdapter(models)
    ...

我们甚至还可以在 RecycleView 中嵌套 RecycleView,用 RecyclerAdapter 实现纵横交错的布局。

上面我们用 onCreateViewHolder 方法通过核心参数 viewType 在同一个 Adapter 中创建出我们想要的各种layout视图,接下来我们需要对视图的数据进行绑定。视图的引用保存在 RecyclerAdapter.ViewHolder 中,这里我们优化一下这个类:

class ViewHolder(val context: Context, val view: View) : RecyclerView.ViewHolder(view) {

    private val views: SparseArray<View> = SparseArray()

    fun <T: View> findView(key: Int): T {
        var v = views[key]
        if (v == null) {
            v = view.findViewById<T>(key)
            views.put(key, v)
        }
        @Suppress("UNCHECKED_CAST")
        return v as T
    }
}

各类View是以树的形式组织的,而且 RecyclerView 会对 viewType 相同的视图进行回收重用,也就是 Recycle,为了避免大量视图绑定时频繁调用 findViewById 方法(递归),我们使用 SparseArray 来缓存视图中的子视图(其实优化效果并不明显 _)。

接下来我们定义一个函数别名用作数据绑定的接口:

typealias OnModelViewBind = (
    index: Int,
    viewModel: RecyclerAdapter.ViewModel,
    viewHolder: RecyclerAdapter.ViewHolder
    ) -> Unit

index 为视图模型的下标,等同于 position

RecyclerAdapter 定义一个函数引用:

// RecyclerAdapter
...
    var onModelViewBind: OnModelViewBind? = null
...

然后 onBindViewHolder 函数实现如下:

// RecyclerAdapter
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
    val model = models[position]
    onModelViewBind?.invoke(position, model, viewHolder)
}

于是在外部代码中,视图与数据的绑定代码就这样写:

// Some Activity or Fragment
adapter.onModelViewBind = { index, viewModel, viewHolder ->
    when (viewModel.layout) {
        R.layout.layout_1 -> {
            // get view from viewHolder.findView function
            // get model form viewModel.value
        }
        R.layout.layout_2 -> {
            // get view from viewHolder.findView function
            // get model form viewModel.value
        }
        R.layout.layout_3 -> {
            // get view from viewHolder.findView function
            // get model form viewModel.value
        }
    }
}

我们还需要两个函数来为 RecyclerAdapter 初始化和添加 ViewModel,请仔细看它们的区别:

// RecyclerAdapter
...
    /**
     * initial the items of recycler adapter
     * @param items items to display
     */
    fun setItems(items: ArrayList<ViewModel>) {
        models.clear()
        models.addAll(items)
        notifyDataSetChanged()
    }

    /**
     * add the items of recycler adapter
     * @param items items to add
     */
    fun addItems(items: ArrayList<ViewModel>) {
        models.addAll(items)
        notifyDataSetChanged()
    }
...

接下来我们给 RecyclerView 的每一个视图项添加单击事件和长按事件,先定义两个函数别名作为单击事件和长按事件的接口:

...
typealias OnModelViewClick = (
    index: Int, 
    viewModel: RecyclerAdapter.ViewModel
    ) -> Unit

typealias OnModelViewLongClick = (
    index: Int,
    viewModel: RecyclerAdapter.ViewModel
    ) -> Unit
...

然后为 RecyclerAdapter 定义两个函数引用:

    // RecyclerAdapter
    ...
    var onModelViewClick: OnModelViewClick? = null

    var onModelViewLongClick: OnModelViewLongClick? = null
    ...

继承 View.OnClickListenerView.OnLongClickListener 接口并实现其方法:

class RecyclerAdapter(private val context: Context, private val spanCount: Int = 1)
    : RecyclerView.Adapter<RecyclerView.ViewHolder>(), View.OnClickListener, View.OnLongClickListener {

    override fun onClick(view: View) {

        val position = recyclerView.getChildAdapterPosition(view)

        if (!models.isEmpty() && position >= 0) {
            val model = models[position]
            onModelViewClick?.invoke(position, model)
        }
    }

    override fun onLongClick(view: View): Boolean {

        val position = recyclerView.getChildAdapterPosition(view)

        if (!models.isEmpty() && position >= 0) {
            val model = models[position]
            onModelViewLongClick?.invoke(position, model)
        }
        return true
    }
}

修改 onCreateViewHolder 方法:

override fun onCreateViewHolder(parent: ViewGroup, type: Int): RecyclerView.ViewHolder {
    // type is layout
    // see fun getItemViewType
    val view = LayoutInflater.from(parent.context).inflate(type, parent, false)
    view.setOnClickListener(this)
    view.setOnLongClickListener(this)
    return ViewHolder(view)
}

这样便实现了 RecyclerView 的视图项的单击和长按事件。

我们还可以给 RecyclerAdapter 添加更多的功能,用来支持更多的场景:

  • 视图模型数量为零时显示一个 空视图
  • 视图模型的多选
  • RecyclerView 添加下拉刷新和上拉加载更多
  • ...

文章开头提到的 App Store 典型布局(分别用kotlinjava实现)以及更多具体详情示例,可以在我的 github 项目 recyclerkit 中查看

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容

  • 目录介绍 1.RecycleView的结构 2.Adapter2.1 RecyclerView.Adapter扮演...
    杨充211阅读 3,063评论 3 17
  • 【Android 控件 RecyclerView】 概述 RecyclerView是什么 从Android 5.0...
    Rtia阅读 307,380评论 27 439
  • 这篇文章分三个部分,简单跟大家讲一下 RecyclerView 的常用方法与奇葩用法;工作原理与ListView比...
    LucasAdam阅读 4,377评论 0 27
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,693评论 2 59
  • 人与人之间的差距就在于沙漠中一粒沙子与另外一粒沙子的区别,同样是一句话,有的人看来,就会觉得自己很渺小,但是有的人...
    刻意练习社区阅读 368评论 3 3