背景

Android利用中常常采纳列表的形式展现信息,有些展现信息是须要分组的模式展现。比方在联系人列表中,列表依照姓名拼音的首字母进行分组显示。分组头显示首字母,分组头被推到顶部时会悬停在顶部直到被下一个分组头顶出。

这样的显示方式能够让用户时刻理解以后展现的数据是哪一组的,晋升了用户体验。

技术剖析

当初支流的列表展现计划是应用RecyclerView,所以这里基于RecyclerView来剖析如何实现可悬浮的分组头性能。

网上有很多实现都是基于scroll listener来确定悬浮 Header的挪动地位。这个监听只有用户滑动时能力接管到事件,所以在初始化时或是数据更新时,悬浮 Header的地位解决比拟麻烦。那么咱们有没有更好的形式监听滑动并能解决这种初始状态呢?

咱们在应用RecyclerView的时候常常要为item增加分割线,增加分割线通常是通过ItemDecoration来实现的。分割线也是能依据用户的滑动扭转地位的,它与悬浮 Header有相似的解决逻辑。在ItemDecoration描绘时,咱们能够获取到画面内view的地位信息,通过这些地位信息,咱们能够确定悬浮 Header的地位。这种形式也达到了滚动监听的目标。

ItemDecoration实现Floating Header

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中实现它的测量、布局和描述。上面这部分代码实现了测量和布局,为了有更好的性能,这里只有在父布局大小变动时才进行测量和布局。

    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不能响应用户的点击事件。

ItemDecoration实现可点击的Floating Header

思考到悬浮的header也要响应点击事件,所以这里就须要思考把header放到view的零碎中。首先如果能增加到RecyclerView中,那么咱们能够管制影响范畴最小化,只在Decoration中实现就能够了,然而增加到RecyclerView后,RecyclerView无奈辨别Item和header,毁坏了原来的RecyclerView治理child view的逻辑。
咱们为了不影响RecyclerView外部解决逻辑,这里把RecyclerView和Header view放到雷同的容器中,

<?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须要向上挪动。看代码中的计算还是比较简单的。

如何判断item类型是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保留数据分组的名,用于辨别分组

如何获取item view的绑定数据

咱们能够通过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    }}

总结

  1. 应用Decoration的形式实现Floating header能够不必思考初始化和数据更新后的地位问题。因为Decoration是在recyclerView更新时调用。
  2. 不响应事件的Floating header不须要批改xml文件,对已有代码侵入小,更好集成。然而Floating header没有被增加到view零碎,所以Decoration须要辅助它的测量、布局和描述。
  3. 响应事件的Floating header须要批改xml文件,然而Decoration中不须要实现Floating header的测量、布局和描述,只须要更改Floating header的translationY就能够了。
  4. 在Decoration中须要通过ViewHolder来获取与之绑定的数据并判断item数据是header还是一般的数据,所以须要再Adapter中实现双向绑定。
  5. 自定义的adapter把绑定操作下发到数据实现,很好的遵循了开闭准则。咱们在实现不同的列表界面时不须要再独自定义adapter了,咱们只须要增加新的数据item定义就能够了。

git

https://github.com/mjlong123123/TestFloatingHeader