Android应用中常常采用列表的方式展现信息,有些展现信息是须要分组的形式展现。好比在联系人列表中,列表按照姓名拼音的首字母进行分组显示。分组头显示首字母,分组头被推到顶部时会悬停在顶部直到被下一个分组头顶出。android
这样的显示方式可让用户时刻了解当前展现的数据是哪一组的,提高了用户体验。git
如今主流的列表展现方案是使用RecyclerView,因此这里基于RecyclerView来分析如何实现可悬浮的分组头功能。github
网上有不少实现都是基于scroll listener来肯定悬浮 Header的移动位置。这个监听只有用户滑动时才能接收到事件,因此在初始化时或是数据更新时,悬浮 Header的位置处理比较麻烦。那么咱们有没有更好的方式监听滑动并能处理这种初始状态呢?segmentfault
咱们在使用RecyclerView的时候常常要为item添加分割线,添加分割线一般是经过ItemDecoration来实现的。分割线也是能根据用户的滑动改变位置的,它与悬浮 Header有相似的处理逻辑。在ItemDecoration描画时,咱们能够获取到画面内view的位置信息,经过这些位置信息,咱们能够肯定悬浮 Header的位置。这种方式也达到了滚动监听的目的。app
class FloatingHeaderDecoration(private val headerView: View) : RecyclerView.ItemDecoration() { private val binding = Header1Binding.bind(headerView) override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { //headerView没有被添加到view的描画系统,因此这里须要主动测量和布局。 if (headerView.width != parent.width) { //测量时控件宽度按照parent的宽度设置确切的大小,控件的高度按照最大不超过parent的高度。 headerView.measure(View.MeasureSpec.makeMeasureSpec(parent.width, EXACTLY), View.MeasureSpec.makeMeasureSpec(parent.height, AT_MOST)) //默认布局位置在parent的顶部位置。 headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight) } if (parent.childCount > 0) { //获取第一个可见item。 val child0 = parent[0] //获取holder。 val holder0 = parent.getChildViewHolder(child0) as? BaseAdapter.BaseViewHolder //获取实现接口IFloatingHeader 的item。 val iFloatingHeader = (holder0?.baseItem as? IFloatingHeader) //header内容绑定。 binding.groupTitle.text = iFloatingHeader?.headerTitle ?: "none" //查找下一个header view val nextHeaderChild = findNextHeaderView(parent) if (nextHeaderChild == null) { //没找到的状况下显示在parent的顶部 binding.root.draw(c) } else { //float header默认显示在顶部,它有可能被向上推,因此它的translationY<=0。经过下一个header的位置计算它被推进的距离 val translationY = (nextHeaderChild.top.toFloat() - binding.root.height).coerceAtMost(0f) c.save() c.translate(0f, translationY) binding.root.draw(c) c.restore() } } } private fun findNextHeaderView(parent: RecyclerView): View? { for (index in 1 until parent.childCount) { val childNextLine = parent[index] val holderNextLine = parent.getChildViewHolder(childNextLine) as? BaseAdapter.BaseViewHolder val iFloatingHeaderNextLine = (holderNextLine?.baseItem as? IFloatingHeader) //查找下一个header的view if (iFloatingHeaderNextLine?.isHeader == true) { return childNextLine } } return null } }
构造函数的参数headerView就是悬浮显示的悬浮 Header,它没有被添加到view的显示系统,因此咱们要在ItemDecoration中完成它的测量、布局和描绘。下面这部分代码实现了测量和布局,为了有更好的性能,这里只有在父布局大小变化时才进行测量和布局。ide
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { //headerView没有被添加到view的描画系统,因此这里须要主动测量和布局。 if (headerView.width != parent.width) { //测量时控件宽度按照parent的宽度设置确切的大小,控件的高度按照最大不超过parent的高度。 headerView.measure(View.MeasureSpec.makeMeasureSpec(parent.width, EXACTLY), View.MeasureSpec.makeMeasureSpec(parent.height, AT_MOST)) //默认布局位置在parent的顶部位置。 headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight) } ...... }
这部分代码的做用是判断顶部显示的item属于哪一组的,而且将组信息绑定到Floating Header。函数
if (parent.childCount > 0) { //获取第一个可见item。 val child0 = parent[0] //获取holder。 val holder0 = parent.getChildViewHolder(child0) as? BaseAdapter.BaseViewHolder //获取实现接口IFloatingHeader 的item。 val iFloatingHeader = (holder0?.baseItem as? IFloatingHeader) //header内容绑定。 binding.groupTitle.text = iFloatingHeader?.headerTitle ?: "none"
这里进行查找下一组的 Header item,根据下一组的 Header item位置来控制当前组头的悬浮位置并描绘。布局
//查找下一个header view val nextHeaderChild = findNextHeaderView(parent) if (nextHeaderChild == null) { //没找到的状况下显示在parent的顶部 binding.root.draw(c) } else { //float header默认显示在顶部,它有可能被向上推,因此它的translationY<=0。经过下一个header的位置计算它被推进的距离 val translationY = (nextHeaderChild.top.toFloat() - binding.root.height).coerceAtMost(0f) c.save() c.translate(0f, translationY) binding.root.draw(c) c.restore() }
因为这里的悬浮header没有被添加到view系统,因此这个header不能响应用户的点击事件。性能
考虑到悬浮的header也要响应点击事件,因此这里就须要考虑把header放到view的系统中。首先若是能添加到RecyclerView中,那么咱们能够控制影响范围最小化,只在Decoration中实现就能够了,可是添加到RecyclerView后,RecyclerView没法区分Item和header,破坏了原来的RecyclerView管理child view的逻辑。
咱们为了避免影响RecyclerView内部处理逻辑,这里把RecyclerView和Header view放到相同的容器中,this
<?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=".List1Activity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> <include android:id="@+id/floatingHeaderLayout" android:layout_width="match_parent" android:layout_height="wrap_content" layout="@layout/header_1"/> </androidx.constraintlayout.widget.ConstraintLayout>
include标签部分的布局就是悬浮header的布局,默认的状况下是与RecyclerView的顶部对齐的。悬浮header被顶出屏幕是经过控制悬浮header的translationY来控制的。因为悬浮header覆盖在RecyclerView上而且在view系统上,因此它是能够响应事件的。
下面的代码展现了Decoration使用布局中的悬浮header完成初始化。这里面咱们能够看到Decoration的绑定回调中设置了悬浮header的title和onClick事件。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityList2Binding.inflate(layoutInflater) setContentView(binding.root) floatingHeaderDecoration = FloatingHeaderDecorationExt(binding.floatingHeaderLayout.root) { baseItem -> when (baseItem) { is GroupItem -> { binding.floatingHeaderLayout.groupTitle.text = baseItem.headerTitle binding.floatingHeaderLayout.root.setOnClickListener { Toast.makeText(this, "点击float header ${baseItem.headerTitle}", Toast.LENGTH_LONG).show() } } is NormalItem -> { binding.floatingHeaderLayout.groupTitle.text = baseItem.headerTitle } } } binding.recyclerView.adapter = adapter binding.recyclerView.addItemDecoration(floatingHeaderDecoration) dataSource.commitList(datas) }
ItemDecoration的完整代码:
class FloatingHeaderDecorationExt( private val headerView: View, private val block: (BaseAdapter.BaseItem) -> Unit ) : RecyclerView.ItemDecoration() { override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { if (parent.childCount > 0) { //获取第一个可见item。 val child0 = parent[0] //获取holder。 val holder0 = parent.getChildViewHolder(child0) as? BaseAdapter.BaseViewHolder //获取实现接口IFloatingHeader 的item。 //header内容绑定。 holder0?.baseItem?.let { block.invoke(it) } //查找下一个header view val nextHeaderChild = findNextHeaderView(parent) if (nextHeaderChild == null) { //没找到的状况下显示在parent的顶部 headerView.translationY = 0f } else { //float header默认显示在顶部,它有可能被向上推,因此它的translationY<=0。经过下一个header的位置计算它被推进的距离 headerView.translationY = (nextHeaderChild.top.toFloat() - headerView.height).coerceAtMost(0f) } } } private fun findNextHeaderView(parent: RecyclerView): View? { for (index in 1 until parent.childCount) { val childNextLine = parent[index] val holderNextLine = parent.getChildViewHolder(childNextLine) as? BaseAdapter.BaseViewHolder val iFloatingHeaderNextLine = (holderNextLine?.baseItem as? IFloatingHeader) //查找下一个header的view if (iFloatingHeaderNextLine?.isHeader == true) { return childNextLine } } return null } }
与悬浮header没有被添加到view系统的Decoration相比,这个实现要更加简单一些。悬浮header被添加到view系统后,他的测量、布局和描绘都有view系统负责完成,Decoration中不须要再作这些操做,惟一须要调整的是悬浮header的translationY的值。
//查找下一个header view val nextHeaderChild = findNextHeaderView(parent) if (nextHeaderChild == null) { //没找到的状况下显示在parent的顶部 headerView.translationY = 0f } else { //float header默认显示在顶部,它有可能被向上推,因此它的translationY<=0。经过下一个header的位置计算它被推进的距离 headerView.translationY = (nextHeaderChild.top.toFloat() - headerView.height).coerceAtMost(0f) }
悬浮header的translationY的值根据下一组的header item来决定,当下一组header item 的top与parent的top之间的距离小于悬浮header的height时,悬浮header须要向上移动。看代码中的计算仍是比较简单的。
在Decoration实现中,咱们看到item类型是经过接口IFloatingHeader来判断的,也就是说每个item数据定义都须要实现这个接口。
private fun findNextHeaderView(parent: RecyclerView): View? { for (index in 1 until parent.childCount) { val childNextLine = parent[index] val holderNextLine = parent.getChildViewHolder(childNextLine) as? BaseAdapter.BaseViewHolder val iFloatingHeaderNextLine = (holderNextLine?.baseItem as? IFloatingHeader) //查找下一个header的view if (iFloatingHeaderNextLine?.isHeader == true) { return childNextLine } } return null }
看一下IFloatingHeader接口的定义:
interface IFloatingHeader { val isHeader:Boolean val headerTitle:String }
isHeader字段用于判断是不是header类型的item
headerTitle保存数据分组的名,用于区分分组
咱们能够经过recyclerView.getChildViewHolder(childView)方法方便的获取ViewHolder,可是这个ViewHolder是被复用的,也就是说它能够与多个数据绑定,那如何才能获取正确的绑定数据呢?咱们能够经过构建数据与ViewHolder的双向绑定关系来实现的。
数据与ViewHodler的双向绑定关系的主体是数据和ViewHoder,他们之间的协调者就是RecyclerView的adapter。咱们来看下adapter是如何工做的:
class BaseAdapter<out T : BaseAdapter.BaseItem>(private val dataSource: BaseDataSource<T>) : RecyclerView.Adapter<BaseAdapter.BaseViewHolder>() { init { dataSource.attach(this) } override fun getItemViewType(position: Int) = dataSource.get(position).viewType override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = BaseViewHolder(LayoutInflater.from(parent.context).inflate(viewType, parent, false)) override fun getItemCount() = dataSource.size() override fun getItemId(position: Int) = dataSource.get(position).getStableId() fun getItem(position: Int) = dataSource.get(position) override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { val item = dataSource.get(position) item.viewHolder = holder holder.baseItem = item item.bind(holder, position) } abstract class BaseItem { internal var viewHolder: BaseViewHolder? = null val availableHolder: BaseViewHolder? get() { return if (viewHolder?.baseItem == this) viewHolder else null } abstract val viewType: Int abstract fun bind(holder: BaseViewHolder, position: Int) abstract fun isSameItem(item: BaseItem): Boolean open fun isSameContent(item: BaseItem): Boolean { return isSameItem(item) } fun getStableId() = NO_ID } class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var baseItem: BaseItem? = null val views = SparseArray<View>(4) fun <V : View> findViewById(id: Int): V { var ret = views[id] if (ret == null) { ret = itemView.findViewById(id) checkNotNull(ret) views.put(id, ret) } return ret as V } fun textView(id: Int): TextView = findViewById(id) fun imageView(id: Int): ImageView = findViewById(id) fun checkBox(id: Int): CheckBox = findViewById(id) } abstract class BaseDataSource<T : BaseItem> { private var attachedAdapter: BaseAdapter<T>? = null open fun attach(adapter: BaseAdapter<T>) { attachedAdapter = adapter } abstract fun get(index: Int): T abstract fun size(): Int } }
为了实现数据与ViewHolder的双向绑定,这里定义了数据的基类BaseItem。咱们只关心双向绑定部分的内容,BaseItem的viewHolder字段保存了与之绑定的ViewHodler(有多是脏数据)。availableHolder字段的get方法中判断了ViewHodler的有效性,即BaseItem绑定的ViewHolder也绑定了本身,这时ViewHolder就是有效的。由于ViewHolder能够被复用并绑定不一样的数据,当它绑定到其它数据时,ViewHolder对于当前的BaseItem就是脏数据。
abstract class BaseItem { internal var viewHolder: BaseViewHolder? = null val availableHolder: BaseViewHolder? get() { return if (viewHolder?.baseItem == this) viewHolder else null } abstract val viewType: Int abstract fun bind(holder: BaseViewHolder, position: Int) abstract fun isSameItem(item: BaseItem): Boolean open fun isSameContent(item: BaseItem): Boolean { return isSameItem(item) } fun getStableId() = NO_ID }
再来看下ViewHolder的基类BaseViewHolder。baseItem字段保存的是当前与之绑定的BaseIte。这里的baseItem能够保证是正确的与之绑定的数据。
class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var baseItem: BaseItem? = null val views = SparseArray<View>(4) fun <V : View> findViewById(id: Int): V { var ret = views[id] if (ret == null) { ret = itemView.findViewById(id) checkNotNull(ret) views.put(id, ret) } return ret as V } fun textView(id: Int): TextView = findViewById(id) fun imageView(id: Int): ImageView = findViewById(id) fun checkBox(id: Int): CheckBox = findViewById(id) }
绑定关系是在adapter的bind方法中创建的,代码中清晰的看到BaseItem与BaseViewHolder如何创建的绑定关系。你们能够看到这里的数据与view的绑定下发到BaseItem的bind方法了,这样咱们在实现不一样的列表展现时就不须要更改Adapter了,咱们只须要定义新样式的BaseItem就能够了,这样也很好的遵循了开闭原则。
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { val item = dataSource.get(position) item.viewHolder = holder holder.baseItem = item item.bind(holder, position) }
说了这么多都是在介绍如何构建ViewHolder与数据的双向绑定关系,双向绑定关系创建后咱们就能够方便的经过viewHolder获取BaseItem了。
private fun findNextHeaderView(parent: RecyclerView): View? { for (index in 1 until parent.childCount) { val childNextLine = parent[index] val holderNextLine = parent.getChildViewHolder(childNextLine) as? BaseAdapter.BaseViewHolder val iFloatingHeaderNextLine = (holderNextLine?.baseItem as? IFloatingHeader) //查找下一个header的view if (iFloatingHeaderNextLine?.isHeader == true) { return childNextLine } } return null }
BaseItem咱们定义了两个:GroupItem和NormalItem
class GroupItem(val title:String):BaseAdapter.BaseItem(),IFloatingHeader { override val viewType: Int get() = R.layout.header_1 override val isHeader: Boolean get() = true override val headerTitle: String get() = title override fun bind(holder: BaseAdapter.BaseViewHolder, position: Int) { holder.textView(R.id.groupTitle).text = title } override fun isSameItem(item: BaseAdapter.BaseItem): Boolean { return (item as? GroupItem)?.title == title } }
class NormalItem(val title:String, val groupTitle:String):BaseAdapter.BaseItem(),IFloatingHeader { override val viewType: Int get() = R.layout.item_1 override val isHeader: Boolean get() = false override val headerTitle: String get() = groupTitle override fun bind(holder: BaseAdapter.BaseViewHolder, position: Int) { holder.textView(R.id.titleView).text = title } override fun isSameItem(item: BaseAdapter.BaseItem): Boolean { return (item as? NormalItem)?.title == title } }