关于android:Android-Floating-header悬浮的分组头

68次阅读

共计 13048 个字符,预计需要花费 33 分钟才能阅读完成。

背景

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

正文完
 0