Android應用中常常採用列表的方式展現信息,有些展現信息是須要分組的形式展現。好比在聯繫人列表中,列表按照姓名拼音的首字母進行分組顯示。分組頭顯示首字母,分組頭被推到頂部時會懸停在頂部直到被下一個分組頭頂出。android
這樣的顯示方式可讓用戶時刻了解當前展現的數據是哪一組的,提高了用戶體驗。git
如今主流的列表展現方案是使用RecyclerView,因此這裏基於RecyclerView來分析如何實現可懸浮的分組頭功能。github
網上有不少實現都是基於scroll listener來肯定懸浮 Header的移動位置。這個監聽只有用戶滑動時才能接收到事件,因此在初始化時或是數據更新時,懸浮 Header的位置處理比較麻煩。那麼咱們有沒有更好的方式監聽滑動並能處理這種初始狀態呢?segmentfault
咱們在使用RecyclerView的時候常常要爲item添加分割線,添加分割線一般是經過ItemDecoration來實現的。分割線也是能根據用戶的滑動改變位置的,它與懸浮 Header有相似的處理邏輯。在ItemDecoration描畫時,咱們能夠獲取到畫面內view的位置信息,經過這些位置信息,咱們能夠肯定懸浮 Header的位置。這種方式也達到了滾動監聽的目的。app
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中完成它的測量、佈局和描繪。下面這部分代碼實現了測量和佈局,爲了有更好的性能,這裏只有在父佈局大小變化時才進行測量和佈局。ide
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不能響應用戶的點擊事件。性能
考慮到懸浮的header也要響應點擊事件,因此這裏就須要考慮把header放到view的系統中。首先若是能添加到RecyclerView中,那麼咱們能夠控制影響範圍最小化,只在Decoration中實現就能夠了,可是添加到RecyclerView後,RecyclerView沒法區分Item和header,破壞了原來的RecyclerView管理child view的邏輯。
咱們爲了避免影響RecyclerView內部處理邏輯,這裏把RecyclerView和Header view放到相同的容器中,this
<?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須要向上移動。看代碼中的計算仍是比較簡單的。
在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保存數據分組的名,用於區分分組
咱們能夠經過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 } }