background

In Android applications, information is often presented in lists, and some of the information is presented in groups. For example, in a contact list, the list is grouped according to the first letter of the name in pinyin. The group header displays the initial letter, and when pushed to the top, the group header hovers at the top until it is headed out of the next group.

This display mode can let the user know which group of data is displayed at the moment, and improve the user experience.

Technical analysis

RecyclerView is used in the majority of list display schemes, so here we analyze how to achieve the recyclerView function of the recyclerView.

There are many implementations on the web that are based on scroll listeners to determine where to move the floating Header. This listener only receives events when the user swipes, so the position of the suspended Header can be tricky to handle during initialization or data update. So is there a better way to listen for sliding and handle this initial state?

RecyclerView is used to create dividers for items. We need to create dividers for items by using RecyclerView. The splitter line can also change position according to the user’s swiping, and it has a similar processing logic to the suspended Header. When ItemDecoration is painted, we can get the position information of the view in the screen, through which we can determine the position of the suspended Header. This approach also achieves the purpose of rolling monitoring.

ItemDecoration implements 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 has not been added to the view's paint system, so there is a need for active measurement and layout. if (headerView.width ! = parent.width) {// The control width is set to the parent width, and the control height is set to the maximum height of the parent. headerView.measure(View.MeasureSpec.makeMeasureSpec(parent.width, EXACTLY), The MeasureSpec. MakeMeasureSpec (parent. Height, AT_MOST)) / / the default layout position at the top of the parent position. headerView.layout(0, 0, headerView.measuredWidth, HeaderView. MeasuredHeight)} the if (the parent childCount > 0) {/ / get the first visible item. Val child0 = parent[0] val holder0 = parent.getChildViewHolder(child0) as? BaseAdapter. BaseViewHolder / / for implementing an interface IFloatingHeader item. val iFloatingHeader = (holder0? .baseItem as? IFloatingHeader) // Header content binding. binding.groupTitle.text = iFloatingHeader? .headerTitle ? : "None" // find the next header view val nexTheaderChild = findnExTheaderView (parent) if (nexTheaderChild == null) { Draw (c)} else {//float header is displayed at the top by default. It may be pushed up, so its translationY<=0. By the next position of the header to calculate the distance it is val translationY = (nextHeaderChild. Top. ToFloat () - binding. Root. The height). CoerceAtMost (0 f) 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) // Find the view of the next header if (iFloatingHeader? .isHeader == true) { return childNextLine } } return null } }

The constructor parameter headerView is the suspended Header for the suspended display. It is not added to the view’s display system, so we need to measure, lay out, and paint it in ItemDecoration. The following section of code implements measurement and layout, which are measured and laid out only when the parent layout size changes for better performance.

override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {// HeaderView has not been added to the view's paint system, so there needs to be active measurement and layout. if (headerView.width ! = parent.width) {// The control width is set to the parent width, and the control height is set to the maximum height of the parent. headerView.measure(View.MeasureSpec.makeMeasureSpec(parent.width, EXACTLY), The MeasureSpec. MakeMeasureSpec (parent. Height, AT_MOST)) / / the default layout position at the top of the parent position. headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight) } ...... }

This part of the code determines which group the items displayed at the top belong to and binds the group information to the Floating Header.

If (Parent.childCount > 0) {// Get the first visible item. Val child0 = parent[0] val holder0 = parent.getChildViewHolder(child0) as? BaseAdapter. BaseViewHolder / / for implementing an interface IFloatingHeader item. val iFloatingHeader = (holder0? .baseItem as? IFloatingHeader) // Header content binding. binding.groupTitle.text = iFloatingHeader? .headerTitle ? : "none"

Here, the Header item of the next group is searched, and the suspension position of the current group’s Header is controlled and described according to the position of the next group’s Header item.

// Find the next header view val nexTheaderChild = findnExTheaderView (Parent) if (nexTheaderChild == null) {// If not found, display at the top of Parent Binding.root. draw(C)} else {//float header is displayed at the top by default, it may be pushed up, so its translationY<=0. By the next position of the header to calculate the distance it is val translationY = (nextHeaderChild. Top. ToFloat () - binding. Root. The height). CoerceAtMost (0 f) c.save() c.translate(0f, translationY) binding.root.draw(c) c.restore() }

Because the floating header here is not added to the view system, the header cannot respond to the user’s click event.

ItemDecoration implements the clickable Floating Header

Given that the floating headers also respond to clicks, it’s important to consider putting the headers in the view’s system. RecyclerView can’t tell the difference between Item and header. We can also have the recyclerView present in the present state. We can also have the RecyclerView present in the present state. Destroy the logic of the original RecyclerView management of the child view. We have to put both the recyclerView and the Header view in the same container so that we don’t have to change the recyclerView internal processing logic.

<? The 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>

The layout of the include tag section is the layout of the floating headers, and by default is aligned with the top of the RecyclerView. The floating header is ejected from the screen by controlling the translationY of the floating header. Since the suspended header is overlaid on the RecyclerView and on the View system, it is able to respond to events.

The following code shows that Decoration is initialized using a floating header from the layout. Here we can see that the Decoration callback sets the title and onClick events of the suspended header.

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, "click on the 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) }

The full code for 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) {// RecyclerView.State) {if (Parent.ChildCount > 0) {// RecyclerView.state; Val child0 = parent[0] val holder0 = parent.getChildViewHolder(child0) as? BaseAdapter. BaseViewHolder / / for implementing an interface IFloatingHeader item. // Header content binding. holder0? .baseItem? .let {block.invoke(it)} // Find the next header view val nexTheaderChild = findnExTheaderView (Parent) if (nexTheaderChild == } else {// Float header is displayed at the top by default, it may be pushed up, so its translationY<=0. By the next position of the header to calculate the distance it is pushing headerView. TranslationY = (nextHeaderChild. Top. ToFloat () - headerView. Height). CoerceAtMost (0 f)}  } } 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) // Find the view of the next header if (iFloatingHeader? .isHeader == true) { return childNextLine } } return null } }

This is a simpler implementation than the floating header Decoration that is not added to the view system. After the suspended header is added to the view system, the view system is responsible for the measurement, layout and description of the header. There is no need to do these operations in Decoration. The only thing that needs to be adjusted is the translationY value of the suspended header.

// Find the next header view val nexTheaderChild = findnExTheaderView (Parent) if (nexTheaderChild == null) {// If not found, display at the top of Parent } else {// Float header is displayed at the top by default, it may be pushed up, so its translationY<=0. By the next position of the header to calculate the distance it is pushing headerView. TranslationY = (nextHeaderChild. Top. ToFloat () - headerView. Height). CoerceAtMost (0 f)}

The translationY value of the suspended header is determined according to the header item of the next group. When the distance between the top of the next group header item and the top of the parent group is less than the height of the suspended header, the suspended header needs to be moved upward. It’s easy to look at the calculations in the code.

How do I tell if the item type is header or plain data

In the Decoration implementation, we can see that the item type is determined by the interface IFloateHeader, which means that each item data definition needs to implement this interface.

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) // Find the view of the next header if (iFloatingHeader? .isHeader == true) { return childNextLine } } return null }

Take a look at the definition of the IFloatingReader interface:

interface IFloatingHeader {
    val isHeader:Boolean
    val headerTitle:String
}

The isHeader field is used to determine whether the item headerTitle of type header holds the name of the data group, which is used to distinguish the group

How do I get the item view’s binding data

. We can use recyclerView getChildViewHolder convenient access to ViewHolder (childView) method, but the ViewHolder is reuse, meaning that it can with multiple data binding, that how to acquire the correct binding data? We can do this by building a two-way binding between the data and the ViewHolder. The recyclerView adapter is the coordinator between the data and the viewWhoder. The recyclerView adapter is the coordinator between the data and the viewWhoder. Let’s see how Adapter works:

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
    }
}

To achieve the two-way binding of the data to the ViewHolder, the base class BaseItem for the data is defined here. We are only concerned with the contents of the bidirectional binding part, where the ViewHolder field of BaseItem holds the ViewWhodler (possibly dirty data) that is bound to it. AvailableHolder in the field of the get method to determine the effectiveness of the ViewHodler, namely the ViewHolder BaseItem binding binding himself, also the ViewHolder is effective. Because the ViewHolder can be multiplexed and bound to different data, when it is bound to other data, the ViewHolder is dirty data for the current 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 }

Now look at the base class of the ViewHolder, BaseViewHolder. The BaseItem field holds the Baseite to which it is currently bound. The BaseItem here is guaranteed to be the correct data bound to it.

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)
    }

The binding relationship is established in the Adapter’s bind method. It is clear in the code how BaseItem is bound to BaseViewHolder. As you can see, the binding of the data to the view is sent to the bind method of BaseItem. In this way, we don’t need to change the Adapter when implementing a different list display. We can just define a new BaseItem style, which also follows the Open and Closed principle.

    override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
        val item = dataSource.get(position)
        item.viewHolder = holder
        holder.baseItem = item
        item.bind(holder, position)
    }

So far, we have introduced how to construct a two-way binding relationship between the ViewHolder and the data. Once the two-way binding relationship is established, we can easily retrieve BaseItem from the ViewHolder.

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) // Find the view of the next header if (iFloatingHeader? .isHeader == true) { return childNextLine } } return null }

BaseItem We define two BaseItem: GroupItem and 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 } }

conclusion

  1. Floating headers can be implemented using Decoration without considering the location of initialization and data updates. Because Decoration is called when the RecyclerView is updated.
  2. Floating headers that do not respond to events do not require modification of the XML file, making them less intrusive and easier to integrate with existing code. However, the Floating header is not added to the view system, so Decoration needs to assist in its measurement, layout, and rendering.
  3. Floating header in response to events needs to modify the XML file, but Decoration does not need to implement the measurement, layout and description of the Floating header, only needs to change the translationY of the Floating header.
  4. In Decoration, it is necessary to obtain the bound data through a ViewHolder and determine whether the item data is header or ordinary data, so it is necessary to implement the bi-directional binding in Adapter.
  5. The custom Adapter posts the binding operation to the data implementation, following the Open Closed principle. We don’t need to define Adapter separately when implementing different list interfaces, we just need to add new data item definitions.

git

https://github.com/mjlong123123/TestFloatingHeader