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

老规矩:先上最终效果图

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

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

效果图如下:

实现流程

  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

ini 复制代码
    init {
        layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
    }

设置基本的数据源与adapter

kotlin 复制代码
    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上修改的

kotlin 复制代码
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

less 复制代码
addItemDecoration(LevelDividerItemDecoration(
    UIUtil.dip2px(context, 16.0),
    UIUtil.dip2px(context, 4.0)))

效果图:

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

添加一个PagerSnapHelper

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

kotlin 复制代码
private val mSnapHelper = PagerSnapHelper()
init {
    mSnapHelper.attachToRecyclerView(this)
    layoutManager = mLayoutManager
}

效果图:

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

优化LevelDividerItemDecoration的计算与绘制

kotlin 复制代码
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()
    }
}

效果图:

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

定义一个等级回调接口

kotlin 复制代码
interface OnLevelChangeListener {
    fun onLevelChange(position : Int)
}

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

kotlin 复制代码
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设置等级回调监听

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

效果图:

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

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

kotlin 复制代码
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的开发了,实现了文章开头的动画效果

总结

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

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

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

代码链接 : github.MyCustomView

相关推荐
Yeats_Liao13 分钟前
Spring 定时任务:@Scheduled 注解四大参数解析
android·java·spring
雾里看山2 小时前
【MySQL】 库的操作
android·数据库·笔记·mysql
水瓶丫头站住11 小时前
安卓APP如何适配不同的手机分辨率
android·智能手机
xvch11 小时前
Kotlin 2.1.0 入门教程(五)
android·kotlin
xvch15 小时前
Kotlin 2.1.0 入门教程(七)
android·kotlin
望风的懒蜗牛15 小时前
编译Android平台使用的FFmpeg库
android
浩宇软件开发16 小时前
Android开发,待办事项提醒App的设计与实现(个人中心页)
android·android studio·android开发
ac-er888816 小时前
Yii框架中的多语言支持:如何实现国际化
android·开发语言·php
苏金标17 小时前
The maximum compatible Gradle JVM version is 17.
android
zhangphil17 小时前
Android BitmapShader简洁实现马赛克,Kotlin(一)
android·kotlin