注册
web

10分钟带你用RecyclerView+PagerSnapHelper实现一个等级指示器

老规矩:先上最终效果图


做前端的同学在平常的工作中,很多时候都会接触到各种指示器,这次我就接到一个等级指示器的需求:

RecyclerView横向滚动,item之间有分割线,中间的item会被放大,边上的item会有一定的透明度进行淡化,滚动时要将等级变更回调给外界进行处理,停止滚动后被选中的item需要居中。


效果图如下:


1.gif


实现流程



  1. 创建一个LevelRecyclerView继承RecyclerView,在内部init方法设置它的layoutManager,在外部提供数据源与adapter,然后最简单的RecyclerView就展示出来了
  2. 给每个item添加分割线
  3. 这时候RecyclerView可以随意滚动,给它添加一个PagerSnapHelper,让RecyclerView停下来时,item可以自动居中
  4. 这时又发现首尾的item,由于滚不到RecyclerView的中间,无法被选中,于是调整首尾item的分割线长度,使它们可以滚动到RecyclerView的中间
  5. 给RecyclerView注册滚动监听,在滚动过程中动态修改item的缩放与透明度,并将当前选中等级回调出去
  6. 重写smoothScrollToPosition,方法原实现是将某个item可见,但现在的需求是居中选中,所以要重写

初始化基本的RecyclerView


在LevelRecyclerView初始化时设置一个横向的LinearLayoutManager


    init {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
}

设置基本的数据源与adapter


    private fun initRecycler() {
val list = mutableListOf<Int>()
list.add(R.drawable.icon_vip_level_0)
list.add(R.drawable.icon_vip_level_1)
list.add(R.drawable.icon_vip_level_2)
list.add(R.drawable.icon_vip_level_3)
list.add(R.drawable.icon_vip_level_4)
list.add(R.drawable.icon_vip_level_5)
list.add(R.drawable.icon_vip_level_6)
list.add(R.drawable.icon_vip_level_7)
list.add(R.drawable.icon_vip_level_8)
list.add(R.drawable.icon_vip_level_9)
list.add(R.drawable.icon_vip_level_10)

rv_level.adapter = object : CommonAdapter<Int>(this, R.layout.level_item, list) {
override fun convert(holder: ViewHolder, t: Int, position: Int) {
holder.setImageResource(R.id.iv_image, t)
holder.setOnClickListener(R.id.iv_image) {
rv_level.smoothScrollToPosition(position)
}
}
}
}

效果图:



给每个item添加分割线


创建一个LevelDividerItemDecoration类继承ItemDecoration,构造参数需要传入分割线的水平长度与高度,分割线的颜色为可选参数,重写getItemOffsets与onDraw方法,熟悉ItemDecoration的同学可能会觉得onDraw方法有点眼熟,因为我这个onDraw是在DividerItemDecoration上修改的


class LevelDividerItemDecoration @JvmOverloads constructor(
private val itemDividerHorizontalMargin : Int,
private val dividerHeight : Int,
dividerColor : Int = Color.parseColor("#3A3A3C")
) : ItemDecoration() {

//分割线Drawable
private val mDivider = ColorDrawable(dividerColor)
//分割线绘制区域
private val mBounds = Rect()

/**
* 计算item的分割线需要的尺寸,就是一个偏移量,可简单看成外边距
*/

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
//上下不需要分割线设置为0,左右则是将构造时传入的itemDividerHorizontalMargin设置进去
outRect.set(itemDividerHorizontalMargin, 0, itemDividerHorizontalMargin, 0)
}

/**
* 绘制分割线
*/

override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(canvas, parent, state)

canvas.save()
val top = (parent.height - dividerHeight) / 2
val bottom = top + dividerHeight
if (parent.clipToPadding) {
canvas.clipRect(parent.paddingLeft, top, parent.width - parent.paddingRight, bottom)
}

val childCount = parent.childCount
for (i in 0 until childCount) {
val item = parent.getChildAt(i)
//获取item的Rect,包含它的外边距(包含上面设置进去的偏移量)
parent.layoutManager!!.getDecoratedBoundsWithMargins(item, mBounds)

//左边分割线
mDivider.setBounds(mBounds.left, top, mBounds.left + itemDividerHorizontalMargin, bottom)
mDivider.draw(canvas)

//右边分割线
mDivider.setBounds(mBounds.right - itemDividerHorizontalMargin, top, mBounds.right, bottom)
mDivider.draw(canvas)
}
canvas.restore()
}
}

在init中添加到LevelRecyclerView


addItemDecoration(LevelDividerItemDecoration(
UIUtil.dip2px(context, 16.0),
UIUtil.dip2px(context, 4.0)))

效果图:


2.gif


这时候RecyclerView可以随意滚动,给它添加一个PagerSnapHelper,让RecyclerView停下来时,item可以自动居中


添加一个PagerSnapHelper


在内部添加一个PagerSnapHelper,并在init时设置依附于LevelRecyclerView


private val mSnapHelper = PagerSnapHelper()
init {
mSnapHelper.attachToRecyclerView(this)
layoutManager = mLayoutManager
}

效果图:


3.gif


这时又发现首尾的item,由于滚不到RecyclerView的中间,无法被选中,于是优化LevelDividerItemDecoration的计算与绘制,调整首尾item的分割线长度,使它们可以滚动到RecyclerView的中间


优化LevelDividerItemDecoration的计算与绘制


class LevelDividerItemDecoration @JvmOverloads constructor(
private val itemDividerHorizontalMargin : Int,
private val dividerHeight : Int,
dividerColor : Int = Color.parseColor("#3A3A3C")
) : ItemDecoration() {

//分割线Drawable
private val mDivider = ColorDrawable(dividerColor)
//分割线绘制区域
private val mBounds = Rect()

/**
* 计算item的分割线需要的尺寸,就是一个偏移量,可简单看成外边距
*/

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val parentWidth = parent.measuredWidth
val itemWidth = view.layoutParams.width
val lastPosition = parent.adapter?.itemCount?.minus(1) ?: 0
//针对首尾两个item计算它们的左右边距,用parentWidth - itemWidth再除2,可以使item刚好到达RecyclerView的中间
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.set(((parentWidth - itemWidth) * 0.5).toInt(), 0, itemDividerHorizontalMargin, 0)
}
lastPosition -> {
outRect.set(itemDividerHorizontalMargin, 0, ((parentWidth - itemWidth) * 0.5).toInt(), 0)
}
else -> outRect.set(itemDividerHorizontalMargin, 0, itemDividerHorizontalMargin, 0)
}
}

/**
* 绘制分割线
*/

override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(canvas, parent, state)

canvas.save()
val top = (parent.height - dividerHeight) / 2
val bottom = top + dividerHeight
if (parent.clipToPadding) {
canvas.clipRect(parent.paddingLeft, top, parent.width - parent.paddingRight, bottom)
}

//RecyclerView宽度
val parentWidth = parent.measuredWidth
val childCount = parent.childCount
for (i in 0 until childCount) {
val item = parent.getChildAt(i)
//item宽度
val itemWidth = item.measuredWidth
//获取item的Rect,包含它的外边距(包含上面设置进去的偏移量)
parent.layoutManager!!.getDecoratedBoundsWithMargins(item, mBounds)

//左边分割线
if (i == 0 && mBounds.width() > itemWidth + itemDividerHorizontalMargin * 2) {
mDivider.setBounds(0, top, mBounds.right - itemWidth - itemDividerHorizontalMargin, bottom)
} else {
mDivider.setBounds(mBounds.left, top, mBounds.left + itemDividerHorizontalMargin, bottom)
}
mDivider.draw(canvas)

//右边分割线
if (i == childCount - 1 && mBounds.width() > itemWidth + itemDividerHorizontalMargin * 2) {
mDivider.setBounds(mBounds.left + itemWidth + itemDividerHorizontalMargin, top, parentWidth, bottom)
} else {
mDivider.setBounds(mBounds.right - itemDividerHorizontalMargin, top, mBounds.right, bottom)
}
mDivider.draw(canvas)
}
canvas.restore()
}
}

效果图:


4.gif


给RecyclerView注册滚动监听,在滚动过程中动态修改item的缩放与透明度,并将当前选中等级回调出去


定义一个等级回调接口


interface OnLevelChangeListener {
fun onLevelChange(position : Int)
}

添加OnScrollListener,在滚动过程中做了一些计算,每个方法都写了注释,具体看下面代码↓


addOnScrollListener(object : OnScrollListener() {
//系数最大值
private val maxFactor = .45F

/**
* RecyclerView滚动
*/

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val first = mLayoutManager.findFirstVisibleItemPosition()
val last = mLayoutManager.findLastVisibleItemPosition()
val parentCenter = recyclerView.width / 2F
for (i in first..last) {
setItemTransform(i, parentCenter)
}
changeSnapView()
}

/**
* RecyclerView滚动状态改变
*/

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == SCROLL_STATE_IDLE) {
changeSnapView()
}
}

/**
* 对item进行各种变换
* 目前是缩放与透明度变换
*/

private fun setItemTransform(position : Int, parentCenter : Float) {
mLayoutManager.findViewByPosition(position)?.run {
val factor = calculationViewFactor(left.toFloat(), width.toFloat(), parentCenter)
val scale = 1 + factor
scaleX = scale
scaleY = scale
alpha = 1 - maxFactor + factor
}
}

/**
* 计算当前item的缩放与透明度系数
* item的中心离recyclerView的中心越远,系数越小(负相关)
*/

private fun calculationViewFactor(left: Float, width : Float, parentCenter : Float) : Float {
val viewCenter = left + width / 2
val distance = abs(viewCenter - parentCenter) / width
return max(0F, (1F - distance) * maxFactor)
}

/**
* 修改当前居中的item,把当前等级回调给外界
*/

private fun changeSnapView() {
mSnapHelper.findSnapView(mLayoutManager)?.let {
mLayoutManager.getPosition(it).let { position ->
if (lastPosition != position) {
lastPosition = position
levelListener?.onLevelChange(position)
}
}
}
}
})

给LevelRecyclerView设置等级回调监听


rv_level.levelListener = object : LevelRecyclerView.OnLevelChangeListener {
override fun onLevelChange(position: Int) {
Log.e("levelListener","levelListener $position")
tv_level.text = "等级:$position"
}
}

效果图:
1.gif


重写smoothScrollToPosition,方法原实现是将某个item可见,但现在的需求是居中选中,所以要重写


方法的原实现其实就是LinearLayoutManager内部创建了一个LinearSmoothScroller去进行滚动,现在我们创建一个CenterSmoothScroller类去继承LinearSmoothScroller,重写它的calculateDtToFit方法,calculateDtToFit用于计算滚动距离,而calculateSpeedPerPixel计算滚动速度


class CenterSmoothScroller(context: Context?) : LinearSmoothScroller(context) {

override fun calculateDtToFit(viewStart: Int, viewEnd: Int, boxStart: Int,
boxEnd: Int, snapPreference: Int)
: Int {
return boxStart + (boxEnd - boxStart) / 2 - (viewStart + (viewEnd - viewStart) / 2)
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {
return super.calculateSpeedPerPixel(displayMetrics) * 3F
}
}

override fun smoothScrollToPosition(position : Int) {
if (position == lastPosition) return
if (position < 0 || position >= (adapter?.itemCount ?: 0)) return

mLayoutManager.startSmoothScroll(
CenterSmoothScroller(context).apply {
targetPosition = position
}
)
}

到这里就完成了对整个LevelRecyclerView的开发了,实现了文章开头的动画效果
1.gif


总结


10分钟过去了,这个简单的LevelRecyclerView你拿下没有?

觉得不错的话,就不要吝啬你的点赞!

需要整份代码的话,下面链接自提。

代码链接 : github.MyCustomView


作者:小白白猪
来源:juejin.cn/post/7291474028744278016

0 个评论

要回复文章请先登录注册