自定义RecyclerView的HeaderItemDecoration实现悬停和点击事件

需求

  1. 实现分组头部悬停效果。
  2. 悬停部分支持点击事件。

前提

  1. 数据集应该是header/items类似的情形。
  2. 数据集应该已经分组排好序。
  3. 每个item都有对应的一个头部
  4. 第一项应该是header

代码实现

class HeaderItemDecoration(
        parent: RecyclerView,
        private val shouldFadeOutHeader: Boolean = false,
        private val isHeader: (itemPosition: Int) -> Boolean
) : RecyclerView.ItemDecoration() {

    private var currentHeader: Pair<Int, RecyclerView.ViewHolder>? = null

    init {
        parent.adapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
            override fun onChanged() {
                // clear saved header as it can be outdated now
                currentHeader = null
            }
        })

        parent.doOnEachNextLayout {
            // clear saved layout as it may need layout update
            currentHeader = null
        }
        // handle click on sticky header
        parent.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {
            override fun onInterceptTouchEvent(
                    recyclerView: RecyclerView,
                    motionEvent: MotionEvent
            ): Boolean {
                return if (motionEvent.action == MotionEvent.ACTION_DOWN) {
                    val b = motionEvent.y <= currentHeader?.second?.itemView?.bottom ?: 0
                    val second = currentHeader?.second
                    if (b && second is ChannelHotHeadViewHolder && !second.switchSort.isChecked && (motionEvent.x >= second.switchSort.left && motionEvent.x <= second.switchSort.right)) {
                        //点击事件传递
                        EventBus.getDefault().post(ClassifyItemHeadClickEvent())
                    }
                    b
                } else false
            }
        })
    }

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
        //val topChild = parent.getChildAt(0) ?: return
        val topChild = parent.findChildViewUnder(
                parent.paddingLeft.toFloat(),
                parent.paddingTop.toFloat() /*+ (currentHeader?.second?.itemView?.height ?: 0 )*/
        ) ?: return
        val topChildPosition = parent.getChildAdapterPosition(topChild)
        if (topChildPosition == RecyclerView.NO_POSITION) {
            return
        }

        val headerView = getHeaderViewForItem(topChildPosition, parent) ?: return

        val contactPoint = headerView.bottom + parent.paddingTop
        val childInContact = getChildInContact(parent, contactPoint) ?: return

        if (isHeader(parent.getChildAdapterPosition(childInContact))) {
            moveHeader(c, headerView, childInContact, parent.paddingTop)
            return
        }

        drawHeader(c, headerView, parent.paddingTop)
    }

    private fun getHeaderViewForItem(itemPosition: Int, parent: RecyclerView): View? {
        if (parent.adapter == null) {
            return null
        }
        val adapter = parent.adapter
        val headerPosition = getHeaderPositionForItem(itemPosition)
        if (headerPosition == RecyclerView.NO_POSITION) return null
        val headerType = adapter?.getItemViewType(headerPosition) ?: return null
        // if match reuse viewHolder
        if (currentHeader?.first == headerPosition && currentHeader?.second?.itemViewType == headerType) {
            if (currentHeader?.second is ChannelHotHeadViewHolder && adapter is ChannelListAdapter) {
                (currentHeader?.second as ChannelHotHeadViewHolder).switchSort.isChecked = adapter.isDefaultSort
            }

            return currentHeader?.second?.itemView
        }

        val headerHolder = adapter.createViewHolder(parent, headerType)
        if (headerHolder is ChannelClassifyHeadViewHolder) {
            headerHolder.vLine.visibility = View.GONE
        }

        if (adapter is ChannelListAdapter) {
            if (headerType != ChannelListAdapter.HOT_HEAD_TYPE) {
                parent.adapter?.onBindViewHolder(headerHolder, headerPosition)
            } else if (headerHolder is ChannelHotHeadViewHolder) {
                headerHolder.switchSort.isChecked = adapter.isDefaultSort
            }

            fixLayoutSize(parent, headerHolder.itemView)
            // save for next draw
            currentHeader = headerPosition to headerHolder
        }
        return headerHolder.itemView
    }

    private fun drawHeader(c: Canvas, header: View, paddingTop: Int) {
        c.save()
        c.translate(0f, paddingTop.toFloat())
        header.draw(c)
        c.restore()
    }

    private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View, paddingTop: Int) {
        c.save()
        if (!shouldFadeOutHeader) {
            c.clipRect(0, paddingTop, c.width, paddingTop + currentHeader.height)
        } else {
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
                c.saveLayerAlpha(
                        RectF(0f, 0f, c.width.toFloat(), c.height.toFloat()),
                        (((nextHeader.top - paddingTop) / nextHeader.height.toFloat()) * 255).toInt()
                )
            } else {
                c.saveLayerAlpha(
                        0f, 0f, c.width.toFloat(), c.height.toFloat(),
                        (((nextHeader.top - paddingTop) / nextHeader.height.toFloat()) * 255).toInt(),
                        Canvas.ALL_SAVE_FLAG
                )
            }

        }
        c.translate(0f, (nextHeader.top - currentHeader.height).toFloat() /*+ paddingTop*/)

        currentHeader.draw(c)
        if (shouldFadeOutHeader) {
            c.restore()
        }
        c.restore()
    }

    private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? {
        var childInContact: View? = null
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val mBounds = Rect()
            parent.getDecoratedBoundsWithMargins(child, mBounds)
            if (mBounds.bottom > contactPoint) {
                if (mBounds.top <= contactPoint) {
                    // This child overlaps the contactPoint
                    childInContact = child
                    break
                }
            }
        }
        return childInContact
    }

    /**
     * Properly measures and layouts the top sticky header.
     *
     * @param parent ViewGroup: RecyclerView in this case.
     */
    private fun fixLayoutSize(parent: ViewGroup, 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)
        view.layout(0, 0, view.measuredWidth, view.measuredHeight)
    }

    private fun getHeaderPositionForItem(itemPosition: Int): Int {
        var headerPosition = RecyclerView.NO_POSITION
        var currentPosition = itemPosition
        do {
            if (isHeader(currentPosition)) {
                headerPosition = currentPosition
                break
            }
            currentPosition -= 1
        } while (currentPosition >= 0)
        return headerPosition
    }
}

inline fun View.doOnEachNextLayout(crossinline action: (view: View) -> Unit) {
    addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
        action(
                view
        )
    }
}
复制代码

实现逻辑

  1. ItemDecoration的常用用法,通过复写它的3个方法:
    • getItemOffsets 撑开 ItemView 上、下、左、右四个方向的空间。
    • onDraw 在 ItemView 内容之下绘制图形。
    • onDrawOver 在 ItemView 内容之上绘制图形。
  2. 复写onDrawOver我们可以通过绘制ItemView来实现悬停的效果。
    • 在onDrawOver方法内部获取屏幕内可见到第一个View。

      View topChild = parent.getChildAt(0);
      复制代码
    • 确定该View对应的ViewHeader。

       int topChildPosition = parent.getChildAdapterPosition(topChild);
       View currentHeader = getHeaderViewForItem(topChildPosition, parent);
      复制代码
    • 定义drawHeader()方法来绘制RecyclerView中悬停的HeaderView。

  3. 实现一个动画效果:当一个新的HeaderView接近头部时,它应该可以可以push掉顶部的HeaderView,并最终占据头部的位置。
    • 确定头部的是否HeaderView是否遇到即将到的新的HeaderView。
       View childInContact = getChildInContact(parent, contactPoint);
      复制代码
    • 获取接触点(contact point): 我们绘制的HeaderView的底部和即将成为HeaderView的头部。
       int contactPoint = currentHeader.getBottom();
      复制代码
    • 如果列表中的Item即将接近contact point,重绘HeaderView,它的底部和即将到的项的顶部重合。 实现translate() 方法:HeaderView的头部将会慢慢消失,“就好像它被慢慢push到屏幕外直至不可见”。当它完全不可见时,绘制新的HeaderView。
      if (childInContact != null) {
      if (mListener.isHeader(parent.getChildAdapterPosition(childInContact))) {
          moveHeader(c, currentHeader, childInContact);
      } else {
          drawHeader(c, currentHeader);
      }
      复制代码
  4. 点击事件
    • HeaderView内部有一个SwitchButton的点击事件。HeaderView是Canvas绘制的,内部并没有实现OntouchEvent方法,因此无法响应内部事件。这里可以通过拦截事件,然后发通知的方式实现它的点击事件,具体见上面的代码注释,比较容易理解。

参考资料

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享