需求:RecyclerView列表是分类的,有好多section,每个section下有几个item,要在头部固定一个sticky header来显示section信息,列表滑动要有推动section header 的效果。有些库不支持高度可变的sticky header,看了下大部分都是使用ItemDecoration实现的,于是改造了一个简单的,直接使用即可。可以支持不同高度的header: 比如空字符串header用较小的高度;多行字符串,单行字符串。
直接看下效果:
提供假数据:
数据模型
sealed class ItemModel {
class SectionHeader(val label: String): ItemModel()
class Product(val name: String, val count: Int, val price: Double): ItemModel()
}
假数据
object FakeData {
fun buildData() = mutableListOf().apply {
// 增加一个长字符串label的header
add(ItemModel.SectionHeader("This section label is very long, and it contains link. Http://www.should_support_link_clicking.com Please click if you need find more."))
repeat(5) {
add(ItemModel.Product("Banana $it", it + 10, 12.99 + it))
}
add(ItemModel.SectionHeader(" ")) // 增加一个header label是空字符串的case
repeat(6) {
add(ItemModel.Product("Apple $it", it + 20, 5.99 + it))
}
add(ItemModel.SectionHeader("Section 3"))
repeat(3) {
add(ItemModel.Product("Orange $it", it + 1, 4.99 + it))
}
add(ItemModel.SectionHeader("Section 4"))
repeat(5) {
add(ItemModel.Product("Orange $it", it + 1, 4.99 + it))
}
add(ItemModel.SectionHeader("Section 5"))
repeat(7) {
add(ItemModel.Product("Orange $it", it + 1, 4.99 + it))
}
add(ItemModel.SectionHeader("Section 6"))
repeat(3) {
add(ItemModel.Product("Orange $it", it + 1, 4.99 + it))
}
}
}
用ItemDecoration实现StickyHeader
class SectionStickyHeaderItemDecoration(
private val sectionStickyHeaderListener: SectionStickyHeaderListener
) : RecyclerView.ItemDecoration() {
private var headerHeight = 0
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val topChild = parent.getChildAt(0) ?: return
val topChildPosition = parent.getChildAdapterPosition(topChild)
if (topChildPosition == RecyclerView.NO_POSITION) return
val headerPosition = sectionStickyHeaderListener.getHeaderPositionForItem(topChildPosition)
val currentHeader = getHeaderViewForItem(headerPosition, parent)
fixLayoutSize(parent, currentHeader)
val contactPoint = currentHeader.bottom
val childInContact = getChildInContact(parent, contactPoint, headerPosition)
if (childInContact != null && sectionStickyHeaderListener.isHeader(
parent.getChildAdapterPosition(
childInContact
)
)
) {
moveHeader(c, currentHeader, childInContact)
return
}
drawHeader(c, currentHeader)
}
private fun drawHeader(c: Canvas, header: View) {
c.save()
c.translate(0f, 0f)
header.draw(c)
c.restore()
}
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View) {
c.save()
c.translate(0f, (nextHeader.top - currentHeader.height).toFloat())
currentHeader.draw(c)
c.restore()
}
private fun getChildInContact(
parent: RecyclerView,
contactPoint: Int,
currentHeaderPos: Int
): View? {
var childInContact: View? = null
for (i in 0 until parent.childCount) {
var heightTolerance = 0
val child = parent.getChildAt(i)
//measure height tolerance with child if child is another header
if (currentHeaderPos != i) {
val isChildHeader =
sectionStickyHeaderListener.isHeader(parent.getChildAdapterPosition(child))
if (isChildHeader) {
heightTolerance = headerHeight - child.height
}
}
//add heightTolerance if child top be in display area
val childBottomPosition = if (child.top > 0) {
child.bottom + heightTolerance
} else {
child.bottom
}
if (childBottomPosition > contactPoint) {
if (child.top <= contactPoint) {
// This child overlaps the contactPoint
childInContact = child
break
}
}
}
return childInContact
}
// Measures and layouts the top sticky header
private fun fixLayoutSize(parent: RecyclerView, view: View) {
// Specs for parent (RecyclerView)
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
val heightSpec =
View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
// Specs for children (headers)
val childWidthSpec = ViewGroup.getChildMeasureSpec(
widthSpec,
parent.paddingLeft + parent.paddingRight,
view.layoutParams.width
)
val childHeightSpec = ViewGroup.getChildMeasureSpec(
heightSpec,
parent.paddingTop + parent.paddingBottom,
view.layoutParams.height
)
view.measure(childWidthSpec, childHeightSpec)
headerHeight = view.measuredHeight
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
private fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View {
val layoutId = sectionStickyHeaderListener.getHeaderLayout(headerPosition)
val header = LayoutInflater.from(parent.context).inflate(layoutId, parent, false)
sectionStickyHeaderListener.bindHeaderData(header, headerPosition)
return header
}
interface SectionStickyHeaderListener {
fun getHeaderPositionForItem(itemPosition: Int): Int
fun getHeaderLayout(headerPosition: Int): Int
fun bindHeaderData(header: View, headerPosition: Int)
fun isHeader(itemPosition: Int): Boolean
}
}
让Adapter实现必要的接口
class DataAdapter : RecyclerView.Adapter(),
SectionStickyHeaderItemDecoration.SectionStickyHeaderListener {
private val data = mutableListOf()
fun setData(list: List) {
data.clear()
data.addAll(list)
}
override fun getItemViewType(position: Int) =
if (data[position] is ItemModel.SectionHeader) HEADER else ITEM
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflate = LayoutInflater.from(parent.context)
return if (viewType == HEADER) {
val view = inflate.inflate(R.layout.header, parent, false)
HeaderViewHolder(view)
} else {
val view = inflate.inflate(R.layout.item, parent, false)
ProductViewHolder(view)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is HeaderViewHolder)
holder.bind(data[position] as ItemModel.SectionHeader)
else if (holder is ProductViewHolder)
holder.bind(data[position] as ItemModel.Product)
}
override fun getItemCount() = data.size
inner class ProductViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private var title: TextView = view.findViewById(R.id.name)
private var count: TextView = view.findViewById(R.id.count)
private var price: TextView = view.findViewById(R.id.price)
fun bind(product: ItemModel.Product) {
val context = title.context
title.text = product.name
count.text = String.format(context.getString(R.string.count), product.count.toString())
price.text = String.format(context.getString(R.string.price), product.price.toString())
}
}
inner class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) {
var header: TextView = view.findViewById(R.id.list_item_section_text)
fun bind(sectionHeader: ItemModel.SectionHeader) {
header.text = sectionHeader.label
header.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, if (sectionHeader.label.trim().isEmpty()) {
convertPixelsToDp(header.context, EMPTY_HEADER_HEIGHT).toInt()
} else {
ViewGroup.LayoutParams.WRAP_CONTENT
}
)
}
}
private fun convertPixelsToDp(context: Context, px: Float): Float {
return px / (context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
}
override fun getHeaderPositionForItem(itemPosition: Int): Int {
for (i in itemPosition downTo 1) {
if (isHeader(i)) return i
}
return 0
}
override fun getHeaderLayout(headerPosition: Int) = R.layout.header
override fun bindHeaderData(header: View, headerPosition: Int) {
val label = (data[headerPosition] as ItemModel.SectionHeader).label
header.findViewById(R.id.list_item_section_text).apply {
text = label
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
if (label.trim().isEmpty()) {
convertPixelsToDp(header.context, EMPTY_HEADER_HEIGHT).toInt()
} else {
ViewGroup.LayoutParams.WRAP_CONTENT
}
)
}
}
override fun isHeader(itemPosition: Int) = data[itemPosition] is ItemModel.SectionHeader
companion object {
const val HEADER = 0
const val ITEM = 1
const val EMPTY_HEADER_HEIGHT = 75F
}
}
在Activity/Fragment中使用之。
class MainActivity : AppCompatActivity() {
lateinit var recyclerView: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView = findViewById(R.id.recycler_view)
val dataAdapter = DataAdapter()
val stickyHeaderItemDecoration = SectionStickyHeaderItemDecoration(dataAdapter)
val dividerItemDecoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
val linearLayoutManager = LinearLayoutManager(this)
recyclerView.apply {
adapter = dataAdapter
layoutManager = linearLayoutManager
recyclerView.addItemDecoration(stickyHeaderItemDecoration) // sticky header
recyclerView.addItemDecoration(dividerItemDecoration) // 分割线
}
dataAdapter.setData(FakeData.buildData())
}
}