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

相关推荐
诸神黄昏EX42 分钟前
Android 分区相关介绍
android
大白要努力!2 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee2 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood2 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-5 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen7 小时前
MTK Android12 user版本MtkLogger
android·framework
长亭外的少年14 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
建群新人小猿17 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神18 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛18 小时前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee