Android-Paging添加footer和header
首先,如果你是对paging不熟悉或者没用过的童鞋,我强烈推荐这个大佬的文章
反思|Android 列表分页组件Paging的设计与实现:系统概述
他的系列文章写的都很棒,强烈建议都看一下 ★★★★★★,要不然你在看到一些类的用法的时候会很懵逼。
当问题出现
动图挂了,先看图片吧 |
相信大家都看出来问题出现在哪里了,当我们以打开paging的页面,并没有显示第一行,而是直接显示了中间的某个部分,为了更好地跟实际项目接轨,这个我们是添加了footer的,用过paging的童鞋们应该都懂,PagedListAdapter是无法获得数据组的,对于出问题的这个界面,我们使用的添加footer的方式与传统的方式类似,(注意在onBindViewHolder里是如何设置瀑布流布局span的)
class CategoryPagingAdapter: PagedListAdapter<FirstClassificationBean.SearchListBean, RecyclerView.ViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when(viewType) {
Int.MIN_VALUE -> CategoryFooterViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.layout_category_footer, parent, false))
else -> CategoryViewHolder.create(getItem(viewType), parent)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (position < itemCount - 1 ) {
(holder as CategoryViewHolder).bindTo(getItem(position), position)
} else {
//这个是把footer设置为整个recyclerview的宽度,用过GridLayoutManager进行recyclerview混排
//的童鞋一定对这个Span不陌生,但是对于瀑布流来说,想设置span为满屏宽度,基本上只有这一个方法
val layoutParams = holder.itemView.layoutParams as StaggeredGridLayoutManager.LayoutParams
layoutParams.isFullSpan = true
}
}
override fun getItemViewType(position: Int): Int {
if (position == itemCount - 1) {
return Int.MIN_VALUE
}
return position
}
override fun getItemCount(): Int {
return super.getItemCount() + 1
}
companion object {
val DIFF = object : DiffUtil.ItemCallback<FirstClassificationBean.SearchListBean>() {
override fun areItemsTheSame(oldItem: FirstClassificationBean.SearchListBean, newItem: FirstClassificationBean.SearchListBean): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: FirstClassificationBean.SearchListBean, newItem: FirstClassificationBean.SearchListBean): Boolean = oldItem.id == newItem.id
}
}
}
如果去网上搜索的话,很容易就能找到
Android官方架构组件Paging-Ex:为分页列表添加Header和Footer
这篇文章,我们如果使用文中的方式添加footer的话,便出现了上文所提出的问题,当然上文的页面不属于我的职责范围,那么我就拿我负责的部分来看
为了不跟其他的部分冲突,我选择了重复造轮子,但是问题依旧存在,当我去掉footer的时候,一切就恢复正常了。
好像不符合上文作者说的😂
虽然上文只阐述了Paging library如何实现Header,实际上对于Footer而言也是一样,因为Footer也可以被视为另外一种的Item;同时,因为Footer在列表底部,并不会影响position的更新,因此它更简单。
现在我面临着学业压力以及开发周期的压力,是来不及看源码了,而且我觉得动不动就重写,修改源码本身不适合这种小毛病的解决,特别是在大项目中,修改源码,怕的是造成莫名的其他错误。
所以我想了个办法
解决方式(不应该提倡的一种奇淫巧计)
注意,本工程使用的MVVM架构
我是这么想的,我们不是不能接触到完整数据吗?但是我还是要改数据,让adapter只用来处理数据,我们让所有的操作都从我们设置进来的数据来推动,数据说是footer,我在adapter里判断,确实是footer(这里有点绕,或者不好理解,大家一会看到代码就懂了),就显示footer,adapter内部尽量不要搞太复杂的事情。我们看看对于使用paging时,哪些地方能够碰到数据。
回忆一下paging(纯网络)需要哪几个部分
- DataSource
- DatasourceFactory
- PagedListAdapter
paging的构建流程是啥呢?
(仅为个人粗鄙的理解,大佬慢点喷,大家尽量不要盲从,我这都是抽象的理解,不是基于大量的源码阅读的基础上的)
①DataSource获得数据——>②callback发送——>③DataSourceFactory获得一个有数据的DataSource的快照——>④DataSourceFactory构建PagedList(然后在ViewModel中设置给一个LiveData)——>⑤View部分监测(专业一点叫观察,我喜欢叫成监测,感觉很酷)ViewModel里的那个设置了PagedList的LiveData——>⑥Adapter.submit(list);
这几个流程中,我们发现③根本没接触到数据,(下图的ids只是我用来远程请求数据的参数,retryExecutor是用来重请求的Executor)
//利用DataSourceFactory来构建一个DataSource的快照
class CategoryDataSourceFactory(
private val retryExecutor: Executor,
private val ids: String
) : DataSource.Factory<String, FirstClassificationBean.SearchListBean>() {
val sourceLiveData = MutableLiveData<CategoryRemoteDataSource>()
override fun create(): DataSource<String, FirstClassificationBean.SearchListBean> {
val source = CategoryRemoteDataSource(retryExecutor, ids)
sourceLiveData.postValue(source)
return source
}
}
④呢?这一步能接触到数据,但是接触到的是LiveData<PagedList<T>>
//利用DataSourceFactory构建LiveData<PagedList<T>>,一般是在ViewModel中使用
resultList = factory.toLiveData(pageSize = 20,
fetchExecutor = executor)
想改动PagedList? Too young, too naive😀,我们来看看这个类的public method
光看方法名就应该感觉到,**的这个类就没想着让你改里面的东西,后面几步也是同样的道理,我们再也接触不到正经的List了,viewmodel里面获得的是pagedlsit,submit()里面传递的是pagedlist。③④⑤⑥直接歇菜,我们只能从①②两步,实施我们的邪恶计划。
正式改动
易知(高中数学老师成天用这句话来伤害我的内心,一做证明题就易知易知),①②一般在datasource里,那么我们可以看看DataSource里有啥
class CategoryRemoteDataSource(
private val retryExecutor: Executor,
private val ids: String
) : PageKeyedDataSource<String, FirstClassificationBean.SearchListBean>() {
private var retry: (() -> Any)? = null
private var page = 1
val networkState = MutableLiveData<NetworkState>()
val initialLoad = MutableLiveData<NetworkState>()
fun retryAllFailed() {
val prevRetry = retry
retry = null
prevRetry.let {
retryExecutor.execute(
it?.invoke() as Runnable?
)
}
}
override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<String, FirstClassificationBean.SearchListBean>) {
networkState.postValue(NetworkState.LOADING)
initialLoad.postValue(NetworkState.LOADING)
CategoriesRepository.getCategoriesDetail(object : BaseObserver<FirstClassificationBean>(null) {
override fun onNext(t: FirstClassificationBean) {
retry = null
networkState.postValue(NetworkState.LOADED)
initialLoad.postValue(NetworkState.LOADED)
callback.onResult(getFadeData2().searchList, t.prevPage.toString(), t.nextPage.toString())
page++
}
override fun onError(e: ApiException?) {
retry = {
loadInitial(params, callback)
}
val error = NetworkState.error(e?.msg)
networkState.postValue(error)
initialLoad.postValue(error)
}
}, "1", params.requestedLoadSize.toString(), ids)
}
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<String, FirstClassificationBean.SearchListBean>) {
networkState.postValue(NetworkState.LOADING)
//这个是网络请求,一般封装过RxJava+Retrofit的童鞋都知道这些基本操作
CategoriesRepository.getCategoriesDetail(object : BaseObserver<FirstClassificationBean>(null) {
override fun onNext(t: FirstClassificationBean) {
networkState.postValue(NetworkState.LOADED)
if (page < 5) {
//在onResult之前,我们就可以对数据进行一番sao操作,
//t.searchList.add(XXXX),这个是正常的操作,对后端来的数据加上一个id为-1的结点
//代表这是Footer的位置
//但是我先用本地数据模拟替代了
callback.onResult(getFadeData2().searchList, t.nextPage.toString())
page++
}
}
override fun onError(e: ApiException?) {
retry = {
loadAfter(params, callback)
}
networkState.postValue(NetworkState.error(e?.msg))
}
}, page.toString(), params.requestedLoadSize.toString(), ids)
}
override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<String, FirstClassificationBean.SearchListBean>) {
}
fun getFadeData2() : FirstClassificationBean {
var goods: MutableList<FirstClassificationBean.SearchListBean> = MutableList(10) {
index ->
var item = FirstClassificationBean.SearchListBean()
item.wants = index
item.name = "大果子"
item.labelIds = "[\"2\",\"3\"]"
item.price = 100.00f
item.images = when (index % 3) {
0, 3 -> "[\"http://q7opnl93o.bkt.clouddn.com/QQ%E5%9B%BE%E7%89%8720190414205835.png\"]"
1 -> "[\"http://q7opnl93o.bkt.clouddn.com/3%EF%BC%9A4.png\"]"
2 -> "[\"http://q7opnl93o.bkt.clouddn.com/4%EF%BC%9A3.png\"]"
else -> "[\"http://q7opnl93o.bkt.clouddn.com/3%EF%BC%9A4.png\"]"
}
item.description = "这是一些测试的数据"
item.userAvatar = "http://q7opnl93o.bkt.clouddn.com/QQ%E5%9B%BE%E7%89%8720190414205835.png"
item.id = System.currentTimeMillis().toInt()
item.userName = "龙猫"
item
}
var data: FirstClassificationBean = FirstClassificationBean()
val footerData = FirstClassificationBean.SearchListBean()
footerData.id = -1
goods.add(footerData)
data.searchList = goods
data.nextPage = 2
data.prevPage = 0
return data
}
}
网络后端传递的数据有点少,我就自己构造了数据。每个数据的区分是靠id,而且正常情况下id不可能小于零,所以我在每次请求回来的分页数据的最后加上了一个id为-1的数据,Σ(っ °Д °;)っ,你为啥突然加个这种数据? 我们就是要用这个数据来占位,表明这个就是footer的位置。
有的眼尖的同学露出了笑容,感觉要翻车。因为你每次请求一部分数据,每次后面都添加了一个footer标志的数据类,会出现啥情况呢?
But,咱们写这个文章就是为了解决这个问题的,经过一些处理,最终没有翻车,我们呈上代码(adapter的代码)
对了,关于footer为啥会占据全屏宽度,上面有代码,下面也有(在onBindViewHolder里),我把adapter里面所有的代码都贴出来了
/** * Time:2020/4/13 20:29 * Author: han1254 * Email: 1254763408@qq.com * Function: */
class CategoryWithFooterAdapter: PagedListAdapter<FirstClassificationBean.SearchListBean, RecyclerView.ViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when(viewType) {
FOOTER_TYPE -> CategoryFooterViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.layout_category_footer, parent, false))
NULL_TYPE -> CategoryFooterViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.layout_paging_null_footer, parent, false))
else -> CategoryViewHolder.create(getItem(viewType), parent)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (getItem(position)?.id == -1 ) {
if (position == itemCount - 1) {
val layoutParams = holder.itemView.layoutParams as StaggeredGridLayoutManager.LayoutParams
layoutParams.isFullSpan = true
}
} else {
(holder as CategoryViewHolder).bindTo(getItem(position), position)
}
}
override fun getItemViewType(position: Int): Int {
if (getItem(position)?.id == -1 ) {
return if (position == itemCount - 1) {
FOOTER_TYPE
} else {
NULL_TYPE
}
}
return position
}
companion object {
val DIFF = object : DiffUtil.ItemCallback<FirstClassificationBean.SearchListBean>() {
override fun areItemsTheSame(oldItem: FirstClassificationBean.SearchListBean, newItem: FirstClassificationBean.SearchListBean): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: FirstClassificationBean.SearchListBean, newItem: FirstClassificationBean.SearchListBean): Boolean = oldItem.id == newItem.id
}
const val FOOTER_TYPE = Int.MIN_VALUE
const val NULL_TYPE = -1
}
}
细心看代码的童鞋可能发现了,我比之前的adapter代码多了一个NULL_TYPE,NULL_TYPE对应的布局为
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="0dp" android:layout_height="0dp">
</androidx.constraintlayout.widget.ConstraintLayout>
Nothing,根本没有任何内容。我摊牌了,到这里我要解释一下我的实现思路——》
- 在datasource里,为每一页数据插入header和footer数据(这些数据一定是要与普通的数据是同类或者是实现了共同接口的)
- 在adapter中,进行判断
override fun getItemViewType(position: Int): Int {
//如果id为-1,代表可能是footer类
if (getItem(position)?.id == -1 ) {
//如果是所有数据的最后一位,确定是footer
return if (position == itemCount - 1) {
FOOTER_TYPE
} else {
//否则,是空视图类,不占任何位置
NULL_TYPE
}
}
//普通数据,直接返回位置
return position
}
那么,我们根据不同的type来渲染不同的view
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when(viewType) {
FOOTER_TYPE -> CategoryFooterViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.layout_category_footer, parent, false))
//我承认我偷懒了,复用了footer的viewholder
NULL_TYPE -> CategoryFooterViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.layout_paging_null_footer, parent, false))
else -> CategoryViewHolder.create(getItem(viewType), parent)
}
}
然后在onBindViewHolder进行类似getItemType()方法中的判断,如果id为-1但是不是最后一位,啥都不做,如果是最后一位且id为-1,则说明应该bind 真正的footer的viewholder了。
那么到现在,我们的需求基本实现了,期间我们没有改变源码(主要还是技术太菜了😰),没有改动基本的paging的使用逻辑,只是在获得数据以及处理数据时做了一点小手脚,方便快捷,感觉用这种方式实现header也是可以的,思路也不太难想。
这就是我的一点小见解,还是那句话,大佬不要喷的太狠😂