@TOC
简介
现在越来越多的电商app都参照了京东和天猫风格的商品列表,商品列表页有一个侧滑筛选菜单,我们产品也不例外,在网上看大部分都是recyclerview嵌套gridview的方式实现的,这样会在一些低配的手机上运行非常卡顿,如果筛选项过多甚至会有一些不可预估的问题(如快速点击,造成数据索引错乱,直接奔溃),用户体验极差。
使用一个recyclerview实现京东筛选菜单
下面我们就来介绍如何使用一个recyclerview就可以 实现京东的筛选菜单。首先,我们先来看筛选菜单有哪些功能:
- 单选和多选 ,筛选项的每一个类目都有可能是单选或多选;
- 展开收起 ,每一个类目如果下面的选项大于6项,那么初始化就显示6项,其余的可点击以及标题进行展开和收起;
- 展示筛选项 ,用户点击某一筛选项时,在父级标题展示所选筛选项;
- 重置,点击重置按钮时,重置所有筛选项;
- 获取筛选项,点击确定按钮时,获取所选项,进行筛选。
screenshot


实现
说了这么多,我们先来看看是怎么实现的吧!
先来定义我们的筛选数据对象:
/**
* 数据对象
*/
data class FilterDao(
// 一级标题对象
var filterParentDao: FilterParentDao? = null,
// 筛选项集合
var sub: List<Sub>? = null
) {
data class Sub(
var id: Int? = null,
var name: String? = null,
var desc: String? = null,
var isShow: Boolean = true,
var isCheck: Boolean = false
)
}
data class FilterParentDao(
var id: Int? = null,
var name: String? = null,
var desc: String? = null,
var isShow: Boolean = false
)
接下来创建一个ItemStatus对象,用于管理和计算我们的item状态:
class ItemStatus {
companion object {
// 父标题 itemType
val VIEW_TYPE_GROUP_ITEM = 0
// 子标题 itemtYPE
val VIEW_TYPE_SUB_ITEM = 1
}
var mViewType: Int = -1
var mGroupItemIndex: Int = -1
var mSubItemIndex: Int = -1
}
既然是列表,那么最重要的肯定就是我们的adapter了:
/**
* Created Kevin by on 2018/10/30.
*/
class GoodsFilterAdapter(private val mDataListTrees: MutableList<FilterDao>, val mContext: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var mGroupItemStatus: MutableList<Boolean> = mutableListOf() // 保存一级标题的开关状态
companion object {
// 子列表收起时,最少展示的数量, 默认是6
private const val MIN_COUNT = 6
}
/**
* 设置显示的数据
*
* @param dataListTrees
*/
fun setData(dataListTrees: List<FilterDao>) {
this.mDataListTrees.clear()
this.mDataListTrees.addAll(dataListTrees)
initGroupItemStatus()
notifyDataSetChanged()
}
/**
* 初始化一级列表开关状态
*/
private fun initGroupItemStatus() {
mGroupItemStatus = ArrayList()
for (i in mDataListTrees.indices) {
mGroupItemStatus.add(false)
}
}
/**
* 根据item的位置,获取当前Item的状态
*
* @param position 当前item的位置(此position的计数包含groupItem和subItem合计)
* @return 当前Item的状态(此Item可能是groupItem,也可能是SubItem)
*/
private fun getItemStatusByPosition(position: Int): ItemStatus {
val itemStatus = ItemStatus()
var itemCount = 0
var i = 0
//轮询 groupItem 的开关状态
while (i < mGroupItemStatus.size) {
if (itemCount == position) { //position刚好等于计数时,item为groupItem
itemStatus.mViewType = ItemStatus.VIEW_TYPE_GROUP_ITEM
itemStatus.mGroupItemIndex = i
break
} else if (itemCount > position) { //position大于计数时,item为groupItem(i - 1)中的某个subItem
itemStatus.mViewType = ItemStatus.VIEW_TYPE_SUB_ITEM
itemStatus.mGroupItemIndex = i - 1 // 指定的position组索引
val subSize = mDataListTrees[i - 1].sub!!.size
// 计算指定的position前,统计的列表项和
val temp = itemCount - subSize
LogUtils.i("\ntemp === " + temp + ";itemcount === " + itemCount + ";subsize === " + subSize + ";pos === " + position)
// 指定的position的子项索引:即为position-之前统计的列表项和
if (!mGroupItemStatus[i - 1]) {
val ind = if (subSize > MIN_COUNT) {
position - temp - subSize + MIN_COUNT
} else {
position - temp
}
itemStatus.mSubItemIndex = ind
} else {
itemStatus.mSubItemIndex = position - temp
}
break
}
val subSize = mDataListTrees[i].sub!!.size
val realCount = if (subSize > MIN_COUNT) MIN_COUNT else subSize
itemCount += realCount + 1
if (mGroupItemStatus[i]) {
itemCount += if (subSize > MIN_COUNT) subSize - MIN_COUNT else 0
}
i++
}
// 轮询到最后一组时,未找到对应位置
if (i >= mGroupItemStatus.size) {
itemStatus.mViewType = ItemStatus.VIEW_TYPE_SUB_ITEM // 设置为二级标签类型
itemStatus.mGroupItemIndex = i - 1 // 设置一级标签为最后一组
val subSize = mDataListTrees[i - 1].sub!!.size
val temp = itemCount - subSize
LogUtils.i("\ntemp2 === " + temp + ";itemcount === " + itemCount + ";subsize === " + subSize + ";pos === " + position)
// 指定的position的子项索引:即为position-之前统计的列表项和
if (!mGroupItemStatus[i - 1]) {
val ind = if (subSize > MIN_COUNT) {
position - temp - subSize + MIN_COUNT
} else {
position - temp
}
itemStatus.mSubItemIndex = ind
} else {
itemStatus.mSubItemIndex = position - temp
}
}
return itemStatus
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view: View
val viewHolder: RecyclerView.ViewHolder
if (viewType == ItemStatus.VIEW_TYPE_GROUP_ITEM) {
view = LayoutInflater.from(mContext).inflate(R.layout.goods_item_filter, parent, false)
viewHolder = GroupItemViewHolder(view)
} else {
view = LayoutInflater.from(mContext).inflate(R.layout.goods_item_filter_sub, parent, false)
viewHolder = SubItemViewHolder(view)
}
return viewHolder
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemStatus = getItemStatusByPosition(position) // 获取列表项状态
val data = mDataListTrees[itemStatus.mGroupItemIndex]
if (itemStatus.mViewType == ItemStatus.VIEW_TYPE_GROUP_ITEM) { // 组类型
val groupItemViewHolder = holder as GroupItemViewHolder
groupItemViewHolder.mTvFilter.text = data.filterParentDao?.name
val groupIndex = itemStatus.mGroupItemIndex // 组索引
//点击父标题时进行展开和收起
groupItemViewHolder.itemView.setOnClickListener {
// initGroupItemStatus() // 如果想要实现只展开一个组的功能
mGroupItemStatus[groupIndex] = !mGroupItemStatus[groupIndex]
notifyDataSetChanged()
groupItemViewHolder.mCbDesc.isChecked = mGroupItemStatus[groupIndex]
}
} else if (itemStatus.mViewType == ItemStatus.VIEW_TYPE_SUB_ITEM) { // 子项类型
val subItemViewHolder = holder as SubItemViewHolder
subItemViewHolder.mCbSub.text = data.sub!![itemStatus.mSubItemIndex].desc
subItemViewHolder.mCbSub.setOnClickListener {
ToastUtils.showToast(itemStatus.mSubItemIndex.toString()
+ "\n"
+ mDataListTrees[itemStatus.mGroupItemIndex].sub!!.get(itemStatus.mSubItemIndex).desc.toString()
+ "\n"
+ position
)
}
}
}
/**
* 计算列表总item
*/
override fun getItemCount(): Int {
var itemCount = 0
if (0 == mGroupItemStatus.size) {
return itemCount
}
for (i in mDataListTrees.indices) {
itemCount++ // 每个一级标题项+1
val subSize = mDataListTrees[i].sub!!.size
if (mGroupItemStatus[i]) { // 二级标题展开时,再加上二级标题的数量
itemCount += subSize
} else { // 收起时,先判断二级标题数量是否大于最小展示数量
itemCount += if (subSize >= MIN_COUNT) MIN_COUNT else subSize
}
}
return itemCount
}
override fun getItemViewType(position: Int): Int {
return getItemStatusByPosition(position).mViewType
}
/**
* 组项ViewHolder
*/
internal class GroupItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var mTvFilter: TextView = itemView.findViewById(R.id.mTvFilter) as TextView
var mCbDesc: CheckBox = itemView.findViewById(R.id.mCbDesc) as CheckBox
}
/**
* 子项ViewHolder
*/
internal class SubItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val mCbSub: CheckBox = itemView.findViewById(R.id.mCbSub)
}
}
这样这个列表就完成了我们来看看这个adapter中关键的几个方法:
- getItemCount() : 遍历我们传进来的list, itemCount++,这里是加上一个父标题,然后判断开关状态, 如果是展开状态,那么直接加上子级列表的数量,否则要判断子集列表的数量是否大于 “收起后剩余展示数量 ”, 再进行增加。
- getItemStatusByPosition(): 最重要的就是这个方法了, 首先遍历mGroupItemStatus, 其实就是遍历了父级对象集合, 如果itemCount刚好等于position时,代表此条目是一个父级, 那么就保存itemType和父级索引,结束掉循环;否则的话代表这个item是个子级, 然后计算此item的真实子索引,当遍历到最后一组的时候,将此item设置为子item,再计算真实子索引。
如上图所示: 当position=0时,组索引为0; 当pos=1是,组索引为0,子索引为0。。。以此类推。
那么继续看while循环, 第一次pos=0;那么itemcount=pos,代表这是一个组索引;结束掉循环; 当走到第二个item时, pos为1, itemcount是0, 那么就计算itemcount的数量, 继续循环:
val subSize = mDataListTrees[i].sub!!.size
val realCount = if (subSize > MIN_COUNT) MIN_COUNT else subSize
itemCount += realCount + 1
if (mGroupItemStatus[i]) {
itemCount += if (subSize > MIN_COUNT) subSize - MIN_COUNT else 0
}
i++
根据图示: 这时 i=1;itemcount = 8,会走到else if (itemCount > position)判断;将其保存为一个子item,它的组索引是0,也就是i-1;
itemStatus.mViewType = ItemStatus.VIEW_TYPE_SUB_ITEM
itemStatus.mGroupItemIndex = i - 1 // 指定的position组索引
val subSize = mDataListTrees[i - 1].sub!!.size
// **计算指定的position前,统计的列表项和(这时,temp = 8-7 = 1)**
val temp = itemCount - subSize
LogUtils.i("\ntemp === " + temp + ";itemcount === " + itemCount + ";subsize === " + subSize + ";pos === " + position)
// 如果是收起状态
if (!mGroupItemStatus[i - 1]) {
val ind = if (subSize > MIN_COUNT) {
position - temp - subSize + MIN_COUNT
} else {
position - temp
}
itemStatus.mSubItemIndex = ind
} else {
// position - temp = (1- 1) = 0
itemStatus.mSubItemIndex = position - temp
}
这样就计算出子item的真实索引,保存到itemstatus对象中了。
设置列表
adapter讲完了,那么如何使用呢?它跟我们平时使用是一样的:
mAdapter = GoodsFilterAdapter(mFilters, activity!!)
val manager = GridLayoutManager(activity, mSpanCount)
// 判断item的类型,如果是子类型,它占据1个item的宽度;如果是父类型;则占据3个item的宽度
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
if (mAdapter.getItemViewType(position) == ItemStatus.VIEW_TYPE_SUB_ITEM) {
return 1
}
return mSpanCount
}
}
mRvFilter.layoutManager = manager
mRvFilter.adapter = mAdapter
侧滑的话可以讲recyclerview放到drawerlayout或slidingmenu里面
这样就大功告成啦!
最后再把adapter的布局文件贴出来:
goods_item_filter.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/mRlTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/common_white">
<LinearLayout
android:id="@+id/rl_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/space_6"
android:layout_marginTop="@dimen/space_16"
android:orientation="horizontal"
android:paddingLeft="@dimen/space_10"
android:paddingRight="@dimen/space_12">
<TextView
android:id="@+id/mTvFilter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="0.2"
android:text="筛选1"
android:textColor="@color/text_color_light"
android:textSize="@dimen/text_size_14" />
<CheckBox
android:id="@+id/mCbDesc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginLeft="@dimen/space_6"
android:layout_marginRight="@dimen/space_6"
android:layout_weight="0.6"
android:button="@null"
android:clickable="false"
android:drawableEnd="@drawable/cb_selector_more"
android:drawablePadding="@dimen/space_6"
android:drawableRight="@drawable/cb_selector_more"
android:ellipsize="end"
android:focusable="false"
android:gravity="right"
android:maxLines="1"
android:paddingLeft="@dimen/space_6"
android:paddingRight="@dimen/space_6"
android:text="234234234234"
android:textColor="@color/text_color_orange"
android:textSize="@dimen/text_size_12" />
</LinearLayout>
</RelativeLayout>
goods_item_filter_sub.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:id="@+id/mRlSub"
android:layout_height="wrap_content">
<CheckBox
android:id="@+id/mCbSub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="false"
android:layout_marginBottom="6dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:background="@drawable/background_cb_filter"
android:button="@null"
android:ellipsize="end"
android:gravity="center"
android:maxEms="4"
android:maxLines="1"
android:paddingBottom="7dp"
android:paddingTop="7dp"
android:singleLine="true"
android:textColor="@color/goods_text_color_cb_filter"
android:textSize="@dimen/text_size_12" />
</RelativeLayout>
这样,一个仿京东、天猫的商品筛选列表就渲染出来了。
结语
我已经把adapter做了一层封装, 实现了数据和业务分离。现已经放到github上了,里面有一个完整的筛选列表的demo,欢迎大家star!
大家可以直接引入:compile 'com.plumcookingwine.tree:TreeRvAdapter:0.0.1';
具体使用方法附在github上。大家可以根据需要自行扩展
github地址: https://github.com/plumcookingwine/TreeAdapter
DEMO地址:https://download.csdn.net/download/qq_22090073/10942079
网友评论