背景
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
}
}
總結
- 使用Decoration的方式實作Floating header可以不用考慮初始化和資料更新後的位置問題。因為Decoration是在recyclerView更新時調用。
- 不響應事件的Floating header不需要修改xml檔案,對已有代碼侵入小,更好內建。但是Floating header沒有被添加到view系統,是以Decoration需要輔助它的測量、布局和描繪。
- 響應事件的Floating header需要修改xml檔案,但是Decoration中不需要實作Floating header的測量、布局和描繪,隻需要更改Floating header的translationY就可以了。
- 在Decoration中需要通過ViewHolder來擷取與之綁定的資料并判斷item資料是header還是普通的資料,是以需要再Adapter中實作雙向綁定。
- 自定義的adapter把綁定操作下發到資料實作,很好的遵循了開閉原則。我們在實作不同的清單界面時不需要再單獨定義adapter了,我們隻需要添加新的資料item定義就可以了。
git
https://github.com/mjlong123123/TestFloatingHeader