Android Floating header(悬浮的分组头)

背景

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
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

mjlong123123

你的鼓励时我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值