10分钟带你实现一个Android自定义View:带动画的等级经验条

先展示一下静态效果图

介绍一下我们的实现流程:

  1. 首先整个经验条有一个圆角边框的背景打底;
  2. 然后给经验条绘制一条轨道,让用户比较直观地看到总进度的长度;
  3. 在轨道的上层绘制我们的渐变色经验条;
  4. 在经验条的上层绘制等级分割点,已达成的等级大圆点,未达成就是小圆点;
  5. 最终再给View加上经验变化的动画效果就实现了。

动态与静态效果图:

按流程实现效果

0.准备工作

定义需要用到的变量

重要的变量都加上注释了,没有注释的就是我觉得没必要注释🚀

ini 复制代码
//整个View的宽度
private var mViewWidth = 0F
//整个View的高度
private var mViewHeight = 0F
//内部经验条的宽度
private var mLineWidth = 0F
//内部经验条的高度
private var mLineHeight = 0F
//内部经验条的左边距
private var mLineLeft = 0F
//内部经验条的上边距
private var mLineTop = 0F
//经验条的圆角
private var mRadius = 0F
//等级圆点的间隔
private var mPointInterval = 0F
//当前经验值
private var mExperience = 0
//每一等级占总长的百分比
private var mLevelPercent = 1F
//经验条百分比(相对于总进度)
private var mExperiencePercent = 1F
//当前等级
private var mCurrentLevel = 0
//升级所需要的经验列表
private val mLevelList = mutableListOf<Int>()

//各种颜色值
private val mPointColor = Color.parseColor("#E1E1E1")
private val mLineColor = Color.parseColor("#666666")
private val mShaderStartColor = Color.parseColor("#18EFE2")
private val mShaderEndColor = Color.parseColor("#0CF191")
private val mStrokeColor = Color.parseColor("#323232")
//各种颜色值

//各种画笔
private val mStrokePaint by lazy {
    Paint().apply {
        color = mStrokeColor
    }
}
private val mShaderPaint by lazy {
    Paint().apply {
        color = mShaderStartColor
    }
}
private val mLinePaint by lazy {
    Paint().apply {
        color = mLineColor
    }
}
private val mLevelAchievedPaint by lazy {
    Paint().apply {
        color = mShaderEndColor
    }
}
private val mLevelNotAchievedPaint by lazy {
    Paint().apply {
        color = mPointColor
    }
}
//各种画笔

重写onMeasure方法,计算View的宽高与各种参数

这里的参数含义在上面大多都已经有注释了,这里就不再多解释,主要说一下做了什么:

通过MeasureSpec去拿到最终的宽度,最终宽度减去我们的左右内边距就是我们要绘制的实际宽度,我们要绘制的经验条宽高比为20:1 ,所以View的最终高度就是宽度的1/20加上上下的内边距;
经验条内部轨道的高度为边框高度的1/3 ,由此算出内部轨道对于边框的四个边距是多少(mLineTop、mLineLeft);

计算完前面的参数就根据轨道的宽度去设置渐变画笔的shader属性,也计算每个等级点之间的间隔距离;

最终将实际的View宽高传给setMeasuredDimension,完成测量工作。

kotlin 复制代码
/**
 * 测量各种尺寸
 */
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val width = MeasureSpec.getSize(widthMeasureSpec)
    mViewWidth = (width - paddingStart - paddingEnd).toFloat()
    val height = mViewWidth / 20 + paddingTop + paddingBottom
    mViewHeight = mViewWidth / 20
    mRadius = mViewHeight

    mLineHeight = mViewHeight / 3
    mLineTop = (mViewHeight - mLineHeight) / 2

    mLineWidth = mViewWidth - mLineTop * 2
    mLineLeft = mLineTop
    setShaderColor()
    computerPointInterval()

    setMeasuredDimension(width, height.toInt())
}

/**
 * 设置经验条的渐变色
 */
private fun setShaderColor() {
    mShaderPaint.shader = LinearGradient(0F, 0F, mLineWidth, 0F,
        mShaderStartColor, mShaderEndColor, Shader.TileMode.CLAMP)
}

/**
 * 计算各个等级点之间的间隔
 */
private fun computerPointInterval() {
    if (mLineWidth > 0F || mLevelList.isNotEmpty()) {
        mPointInterval = mLineWidth / mLevelList.size
    }
}

1.绘制经验条的打底圆角背景框

重写onDraw方法,将画布canvas进行偏移,移除内边距对我们绘制的影响。
saverestore 这两个API是成对使用的,save会保存画布的当前状态,然后我们就可以对画布进行偏移、旋转和缩放等操作,等我们绘制完之后再调用restore,就可以使用画布回到之前的状态了。
translate 方法可以对画布进行偏移,偏移之后我们的所有绘制操作就都基于偏移后的坐标了。
drawBackground 实现了底部边框的绘制,只有一行代码,实现非常简单。
drawRoundRect 这个API的效果就是绘制一个圆角矩形,传入四个边角坐标、圆角和画笔即可。

效果图:

kotlin 复制代码
/**
 * 绘制View
 */
override fun onDraw(canvas: Canvas) {
    canvas.save()
    canvas.translate(paddingStart.toFloat(), paddingTop.toFloat())
    
    drawBackground(canvas)
    
    canvas.restore()
}

/**
 * 绘制背景边框
 */
private fun drawBackground(canvas: Canvas) {
    canvas.drawRoundRect(0F, 0F, mViewWidth, mViewHeight, mRadius, mRadius, mStrokePaint)
}

2.给经验条绘制一条轨道

修改onDraw方法,添加drawExperienceBar方法。
drawExperienceBar 里面也做了一个画布的偏移,然后绘制一个圆角矩形,跟上面的很相似,相信大家都能看懂。

效果图:

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    canvas.save()
    canvas.translate(paddingStart.toFloat(), paddingTop.toFloat())
    
    drawBackground(canvas)
    drawExperienceBar(canvas)
    
    canvas.restore()
}

/**
 * 绘制经验条
 */
private fun drawExperienceBar(canvas: Canvas) {
    val save = canvas.saveCount
    canvas.save()
    canvas.translate(mLineLeft, mLineTop)

    //绘制经验条底部背景
    canvas.drawRoundRect(0F, 0F, mLineWidth, mLineHeight, mRadius, mRadius, mLinePaint)

    canvas.restoreToCount(save)
}

3.在轨道的基础上再画一个渐变色的经验条

还是drawExperienceBar这个方法,在绘制轨道之后,再调用一次drawRoundRect去绘制经验条,这里看起来没什么大的区别,但是需要注意的点有两个:

  1. 绘制轨道的第一个参数x0,从一开始的0F开始修改成了从经验条的右侧开始,这样做是为了尽可能减少过度绘制,也就是下面效果图的图1,轨道实际上只绘制了一部分,如果这里看不懂的话,可以直接忽略;
  2. 绘制渐变进度条的宽度是从0F开始,到mLineWidth * mExperiencePercent结束,mExperiencePercent是前面computerLevelInfo 计算好的百分比,至于它为什么有渐变色,已经在setShaderColor设置了shader属性,支持了线性渐变。

效果图:

kotlin 复制代码
private fun drawExperienceBar(canvas: Canvas) {
    val save = canvas.saveCount
    canvas.save()
    canvas.translate(mLineLeft, mLineTop)

    //绘制经验条底部背景
    canvas.drawRoundRect((mLineWidth * mExperiencePercent - mLineHeight).coerceAtLeast(0F),
        0F, mLineWidth, mLineHeight, mRadius, mRadius, mLinePaint)
    //绘制渐变的经验条
    canvas.drawRoundRect(0F, 0F, mLineWidth * mExperiencePercent, mLineHeight,
        mRadius, mRadius, mShaderPaint)

    canvas.restoreToCount(save)
}

4.在经验条的上层绘制等级分割点,已达成的等级大圆点,未达成就是小圆点

修改onDraw方法,添加drawLevelPoint方法。
drawLevelPoint 方法循环去绘制每个等级的分割点,等级点的间距在上面的computerPointInterval已经计算好了,在循环里面会通过当前的经验进度是否大于等于当前等级的进度,如果是的话就是已达成的等级,否则就是未达成。

这里的drawCircle API可以让我们在画布上绘制一个圆,只要传入圆心坐标、半径和画笔即可。

效果图:

scss 复制代码
override fun onDraw(canvas: Canvas) {
    canvas.save()
    canvas.translate(paddingStart.toFloat(), paddingTop.toFloat())
    
    drawBackground(canvas)
    drawExperienceBar(canvas)
    drawLevelPoint(canvas)
    
    canvas.restore()
}

/**
 * 绘制等级分割点
 */
private fun drawLevelPoint(canvas: Canvas) {
    if (mLevelList.size > 1) {
        val save = canvas.saveCount
        canvas.save()
        canvas.translate(mLineLeft, 0F)

        //等级圆点的圆心Y轴坐标(由于经验条是水平的,所以所有Y轴坐标都一样)
        val cy = mViewHeight / 2
        //总共有n - 1个等级圆点,所以从1开始画,已达成的等级大圆点,未达成就是小圆点
        for (level in 1 until mLevelList.size) {
            //当前等级是否已达成
            val achieved = mExperiencePercent >= mLevelPercent * level
            canvas.drawCircle(mPointInterval * level, cy,
                if (achieved) mLineHeight else mLineHeight / 2,
                if (achieved) mLevelAchievedPaint else mLevelNotAchievedPaint)
        }

        canvas.restoreToCount(save)
    }
}

加上经验变化的动画效果

动画这一块再定义一些变量,当我们设置经验条的经验数据时,内部就会调用startAnimator 方法,通过ValueAnimator的回调,不断地去更新mExperiencePercent,然后刷新View,就可以实现经验条增加经验的动画了。

kotlin 复制代码
//动画相关
private var mAnimator : ValueAnimator? = null
//动画时长
private val mAnimatorDuration = 500L
//插值器
private val mInterpolator by lazy { DecelerateInterpolator() }
//动画值回调
private val mAnimatorListener by lazy {
    ValueAnimator.AnimatorUpdateListener {
        mExperiencePercent = it.animatedValue as Float
        invalidate()
    }
}

/**
 * 开始经验条动画
 */
private fun startAnimator(start : Float, end : Float) {
    mAnimator?.cancel()
    mAnimator = ValueAnimator.ofFloat(start, end).apply {
        duration = mAnimatorDuration
        interpolator = mInterpolator
        addUpdateListener(mAnimatorListener)
        start()
    }
}
//动画相关

开放两个API给外部设置等级和经验信息

不管是哪种更新方式,都会调用startAnimator方法去启动动画修改。

kotlin 复制代码
/**
 * 外界更新经验
 */
fun updateExperience(experience : Int) {
    if (mLevelList.isEmpty() || experience == mExperience) return
    mExperience = experience
    startAnimator(mExperiencePercent, computerLevelInfo())
}

/**
 * 外界设置等级信息
 */
fun setLevelInfo(experience : Int, list : List<Int>) {
    mExperience = experience
    mLevelList.clear()
    mLevelList.addAll(list)
    computerPointInterval()
    startAnimator(0F, computerLevelInfo())
}

自定义View的工作到这里就完成了!

外部的使用代码

XML:

ini 复制代码
<com.hbh.customview.view.ExperienceBar
    android:id="@+id/experience_bar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingHorizontal="20dp"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"/>

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:layout_marginVertical="16dp"
    app:layout_constraintTop_toBottomOf="@id/experience_bar">

    <Button
        android:id="@+id/btn_test1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="test1"
        android:layout_gravity="center"/>

    <Button
        android:id="@+id/btn_test2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="test2"
        android:layout_gravity="center"/>

</LinearLayout>

Activity:

ini 复制代码
val experience_bar = findViewById<ExperienceBar>(R.id.experience_bar)

val a = 5
val b = listOf(10,50,100,250,500,1000)
val btn_test1 = findViewById<Button>(R.id.btn_test1).apply {
    setOnClickListener {
        experience_bar.setLevelInfo(a, b)
    }
}
val btn_test2 = findViewById<Button>(R.id.btn_test2).apply {
    var index = 0
    val c = listOf(888, 188)
    setOnClickListener {
        experience_bar.updateExperience(c[(index++) % c.size])
    }
}

总结

10分钟过去了,这个简单的自定义View你拿下没有?

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

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

代码链接 : github.MyCustomView

相关推荐
雨白1 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹3 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空5 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭5 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日6 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安6 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑6 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟10 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡12 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0012 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体