ListView默认有divider属性,但是RecyclerView没有。有人因此就说RecyclerView是low逼?显然不是这样。谷歌已经将divider的功能完全放权给我们开发者去自由定制了。那么我们就来看看正统的实现方式到底是怎样的
相关代码已经上传到github,欢迎大家star、fork
ItemDecoration
这里我就不卖关子了,直接切入正题。我们需要通过RecyclerView提供的ItemDecoration类来给每条item添加装饰,这里的装饰不仅仅是分隔线,还有其他更为复杂的东西。
复杂的东西我们不说,就讲讲如何绘制divider吧?绘制过程大体上是这样的:首先确定可绘制区域范围,然后在这个区域上进行绘制,绘制既可以在itemView的下方也可以在itemView的上方
初步了解绘制过程之后,我们就来看下ItemDecoration类具体有什么功能
ItemDecoration类中有3个方法(抛开已过时的不算):getItemOffsets、onDraw、onDrawOver。
getItemOffsets:
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(), parent);
}
此方法作用于每个itemView,使用时直接分别用outRect.top=x、outRect.bottom=x、outRect.left=x、outRect.right=x在itemView的上下左右方向撑出x像素的空间,效果类似于给每个itemView添加了padding值,视图向中间收缩。此方法直接对每个itemView生效,参数中的view就是当前item的视图。在使用过程中我们只需处理特殊项,无需遍历(如首项尾项可能分别不需要上分割线和下分割线,此时无需为他们撑开上下空间)。
下图应该很形象的展示设置后的效果
onDraw:
public void onDraw(Canvas c, RecyclerView parent, State state) {
onDraw(c, parent);
}
此方法使用Canvas在itemView的下层空间进行绘制,所以可能被itemView遮挡住,因此一定要在getItemOffsets方法撑开的空白区域内进行绘制。这个方法的参数中并没有提供每个item的视图,所以要通过遍历拿到要绘制的item的视图,根据视图的位置确定Decoration的绘制位置。
onDrawOver:
public void onDrawOver(Canvas c, RecyclerView parent, State state) {
onDrawOver(c, parent);
}
此方法使用Canvas在itemView的上层空间进行绘制,使用方法和onDraw方法相同,不同的是可能会遮挡Item的视图内容。所以使用时一定要把内容绘制在正确的位置上,不要遮挡了界面上的有效内容。
需要注意的是,此处没有任何点击响应事件
实战
下面我们通过几个小例子来学习一下
- 绘制水平分隔线与垂直分隔线
这个应该算最寻常的需求了
我准备绘制一条蓝色的divider,这个divider是一个drawable
线有了就开始绘制吧,首先继承ItemDecoration
class VerticalDecoration : RecyclerView.ItemDecoration() {
override fun onDrawOver(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDrawOver(c, parent, state)
}
override fun onDraw(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDraw(c, parent, state)
}
override fun getItemOffsets(outRect: Rect?, view: View?, parent: RecyclerView?, state: RecyclerView.State?) {
super.getItemOffsets(outRect, view, parent, state)
}
}
当然这里我们不需要onDrawOver。我们仅需要显示底部的分隔线,所以rect只要设置底部即可,值为drawable的高度。注意考虑到itemView的margin还有其可能存在的属性动画效果
class Divider(val context: Context, val drawable: Drawable) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDraw(c, parent, state)
if (parent != null) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = parent.paddingLeft + params.leftMargin + child.translationX.toInt()
val right = parent.width - params.rightMargin + child.translationX.toInt()
val top = child.bottom + params.bottomMargin + child.translationY.toInt()
val bottom = top + drawable.intrinsicHeight
drawable.bounds = Rect(left, top, right, bottom)
drawable.draw(c)
}
}
}
override fun getItemOffsets(outRect: Rect?, view: View?, parent: RecyclerView?, state: RecyclerView.State?) {
super.getItemOffsets(outRect, view, parent, state)
outRect?.set(Rect(0, 0, 0 ,drawable?.intrinsicHeight))
}
}
看看效果
横向的看完了,那纵向的应该问题也不大吧。就是底部变成右侧而已了。这里也不啰嗦了,小改造一下
class Divider(val context: Context, val drawable: Drawable, val orientation: Int = LinearLayoutManager.VERTICAL) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDraw(c, parent, state)
if (parent != null) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
var left = 0
var right = 0
var top = 0
var bottom = 0
if (orientation == LinearLayoutManager.VERTICAL) {
left = parent.paddingLeft + params.leftMargin + child.translationX.toInt()
right = parent.width - params.rightMargin + child.translationX.toInt()
top = child.bottom + params.bottomMargin + child.translationY.toInt()
bottom = top + drawable.intrinsicHeight
}
else {
left = child.right + params.rightMargin + child.translationX.toInt()
right = left + drawable.intrinsicWidth
top = child.top - params.topMargin + child.translationY.toInt()
bottom = child.bottom + params.bottomMargin + child.translationY.toInt()
}
drawable.bounds = Rect(left, top, right, bottom)
drawable.draw(c)
}
}
}
override fun getItemOffsets(outRect: Rect?, view: View?, parent: RecyclerView?, state: RecyclerView.State?) {
super.getItemOffsets(outRect, view, parent, state)
if (orientation == LinearLayoutManager.VERTICAL) {
outRect?.set(Rect(0, 0, 0 ,drawable?.intrinsicHeight))
}
else {
outRect?.set(Rect(0, 0 ,drawable?.intrinsicWidth, 0))
}
}
}
- 绘制表格分隔线
其实你把之前横向纵向都搞清楚了,这个也没什么难度,都是依葫芦画瓢
class GridDivider constructor(val context: Context, val dividerDrawable: Drawable) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDraw(c, parent, state)
drawLeft(c, parent)
drawTop(c, parent)
drawRight(c, parent)
drawBottom(c, parent)
}
private fun drawTop(c: Canvas?, parent: RecyclerView?) {
if (parent != null) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.left - params.leftMargin + child.translationX.toInt()
val right = child.right + params.rightMargin + child.translationX.toInt()
val bottom = child.top - params.topMargin + child.translationY.toInt()
val top = bottom - dividerDrawable?.intrinsicHeight
dividerDrawable?.bounds = Rect(left, top, right, bottom)
dividerDrawable?.draw(c)
}
}
}
private fun drawLeft(c: Canvas?, parent: RecyclerView?) {
if (parent != null) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val right = child.left - params.leftMargin + child.translationX.toInt()
val left = right - dividerDrawable?.intrinsicWidth
val top = child.top - params.topMargin + child.translationY.toInt()
val bottom = child.bottom + params.bottomMargin + child.translationY.toInt()
dividerDrawable?.bounds = Rect(left, top, right, bottom)
dividerDrawable?.draw(c)
}
}
}
private fun drawBottom(c: Canvas?, parent: RecyclerView?) {
if (parent != null) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.left - params.leftMargin + child.translationX.toInt()
val right = child.right + params.rightMargin + child.translationX.toInt()
val top = child.bottom + params.bottomMargin + child.translationY.toInt()
val bottom = top + dividerDrawable?.intrinsicHeight
dividerDrawable?.bounds = Rect(left, top, right, bottom)
dividerDrawable?.draw(c)
}
}
}
private fun drawRight(c: Canvas?, parent: RecyclerView?) {
if (parent != null) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.right + params.rightMargin + child.translationX.toInt()
val right = left + (dividerDrawable?.intrinsicWidth ?: 0)
val top = child.top - params.topMargin + child.translationY.toInt()
val bottom = child.bottom + params.bottomMargin + child.translationY.toInt()
dividerDrawable?.bounds = Rect(left, top, right, bottom)
dividerDrawable?.draw(c)
}
}
}
override fun getItemOffsets(outRect: Rect?, view: View?, parent: RecyclerView?, state: RecyclerView.State?) {
super.getItemOffsets(outRect, view, parent, state)
if (parent != null) {
val position = parent.getChildAdapterPosition(view)
val manager = parent.layoutManager as GridLayoutManager
when {
// 左侧一排加顶上、左边、右边的线
position % manager.spanCount == 0 -> outRect?.set(Rect(dividerDrawable?.intrinsicWidth, dividerDrawable?.intrinsicHeight, dividerDrawable?.intrinsicWidth, 0))
// 其余加顶上的线、右边的线
else -> outRect?.set(Rect(0, dividerDrawable?.intrinsicHeight, dividerDrawable?.intrinsicWidth, 0))
}
}
}
}
其实我这边有一个困惑的地方,我没有画底部的线条,但是它却自己添加进来了,我有点晕乎乎的
- 绘制悬停Section
这个功能一般多用在联系人列表,我在之前的文章中也实现过相应的功能,用的是控制View的移动,那个更实用一点
我们需要在内容发生变化的那一列itemView的头部画此Section区域。怎么判断itemView内容是否与之前的那个不同呢?这个就需要交给adapter去做判断了,然后将判断的结果保存在itemView的tag上,这样这里的ItemDecoration就能通过view参数拿到这个tag了
先来看看adapter的代码,我创建一个SectionBean对象存储新内容判断结果
class StickyHeaderAdapter() : RecyclerView.Adapter() {
var context: Context? = null
var beans: ArrayList? = null
constructor(context: Context, beans: ArrayList) : this() {
this.context = context
this.beans = beans
}
override fun onBindViewHolder(holder: StickyHeaderHolder?, position: Int) {
holder?.tv_adapter!!.text = beans!![position]
val section = SectionBean()
if (position == 0) {
section.start = true
}
else {
section.start = beans!![position] != beans!![position-1]
}
holder?.layout_adapter.tag = section
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): StickyHeaderHolder {
val view: View = LayoutInflater.from(context).inflate(R.layout.adapter_main, parent, false)
return StickyHeaderHolder(view)
}
override fun getItemCount(): Int {
return if (beans == null) {
0
}
else {
beans!!.size
}
}
class StickyHeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tv_adapter: TextView = itemView.find(R.id.tv_adapter)
val layout_adapter: LinearLayout = itemView.find(R.id.layout_adapter)
}
}
然后我们先画Head线,这里我将高度写死了100px,实际开发中需要自己计算
class StickyHeader(val context: Context, val dividerDrawable: Drawable = ContextCompat.getDrawable(context, R.drawable.item_divider)) : RecyclerView.ItemDecoration() {
var paint: Paint? = null
init {
paint = Paint()
paint?.color = Color.BLACK
paint?.textSize = 30.0f
paint?.style = Paint.Style.FILL
}
override fun onDraw(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDraw(c, parent, state)
drawTop(c, parent)
}
private fun drawTop(c: Canvas?, parent: RecyclerView?) {
for (i in 0 until parent?.childCount!!) {
val child = parent?.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val bottom = child.top - params.topMargin + parent.getChildAt(i).translationY.toInt()
val top = bottom - 100
val left = child.left + parent.getChildAt(i).translationX.toInt()
val right = child.right + parent.getChildAt(i).translationX.toInt()
dividerDrawable?.bounds = Rect(left, top, right, bottom)
if ((child.tag as SectionBean).start) {
dividerDrawable?.draw(c)
val fontMetrics: Paint.FontMetricsInt = paint!!.fontMetricsInt
val baseline = (dividerDrawable?.bounds.bottom + dividerDrawable?.bounds.top - fontMetrics.bottom - fontMetrics.top) / 2
c?.drawText(((child as LinearLayout).getChildAt(0) as TextView).text.toString(), left.toFloat(), baseline.toFloat(), paint)
}
}
}
override fun getItemOffsets(outRect: Rect?, view: View?, parent: RecyclerView?, state: RecyclerView.State?) {
super.getItemOffsets(outRect, view, parent, state)
if ((view?.tag as SectionBean).start) {
outRect?.set(0, 100, 0 ,0)
}
else {
outRect?.set(0, 0, 0 ,0)
}
}
}
OK没问题
下面就是重点,我们需要在itemView的上方绘制Section,所以就需要使用到onDrawOver方法了。这时候我们在滚动过程中需要实时调整Section的位置,如果正好处于新老交替的时候,Section就要跟随老的尾巴移动到屏幕上方去,已被新的所取代,这时候Section的底部位置是不是就跟老itemView底部保持一致。一般情况下,Section的位置就是恒定不变的
override fun onDrawOver(c: Canvas?, parent: RecyclerView?, state: RecyclerView.State?) {
super.onDrawOver(c, parent, state)
drawSection(c, parent)
}
private fun drawSection(c: Canvas?, parent: RecyclerView?) {
val view0 = parent?.getChildAt(0)
val view1 = parent?.getChildAt(1)
if (view0 != null && view1 != null) {
val left = view0.left
val right = view1.right
var top = 0
var bottom = 100
if (!(view0.tag as SectionBean).start && (view1.tag as SectionBean).start && view0?.bottom!! < 100) {
bottom = view0?.bottom!!
top = bottom - 100
}
dividerDrawable?.bounds = Rect(left, top, right, bottom)
dividerDrawable?.draw(c)
val fontMetrics: Paint.FontMetricsInt = paint!!.fontMetricsInt
val baseline = (dividerDrawable?.bounds.bottom + dividerDrawable?.bounds.top - fontMetrics.bottom - fontMetrics.top) / 2
c?.drawText(((view0 as LinearLayout).getChildAt(0) as TextView).text.toString(), left.toFloat(), baseline.toFloat(), paint)
}
}
其实这个也不是特别的完美,毕竟这里没有点击事件,还是蛮遗憾的
参考文章
ItemDecoration类的使用