在Android上仿百度贴吧客户端Loading图标小球
封面预览
前言
使用百度贴吧客户端的时候发发现加载的小动画挺有意思的,于是自己动手写写看。想学习自定义View以及自定义动画的小伙伴一定不要错过哦。
读者朋友需要有最基本的canvas绘图功底,比如画笔Paint的简单使用、Path如何画直线等简单的操作,不熟悉也没关系,下文带大家撸代码的时候会简单的讲一下。
此篇文章用到如下知识点:
- 1)、自定义View的测量
- 2)、自定义View属性的自定义及使用
- 3)、Path绘制贝塞尔曲线
- 4)、Canvas的裁剪
- 5)、用ValueAnimator控制动画
- 6)、Canvas文字居中
好了,开始正文!
一、准备工作
1、效果图
loading小球
2、动画拆解
直观的看我们要实现三个方面
1)、波浪动画(蓝色部分)
2)、不规则的文字(白色的半个“贴”字)
3)、控件显示部分限制成圆形
3、技术分析
1)、波浪动画
要实现波浪动画,首先要绘制出波浪的形状,其次再让他动起来。波浪线看起来有点像是正弦或者余弦函数,但是Android的Path并没有提供绘制正余弦图形的函数,但是提供了一个功能更强大的曲线——贝塞尔曲线,贝塞尔曲线分为二阶、三阶及多阶,本案例里使用的是二次贝塞尔曲线,如下图所示,二阶贝塞尔曲线需要三个点才可以确定
二阶贝塞尔曲线
我们来看一下Android里贝塞尔曲线的源码:
- /* @param x1 The x-coordinate of the control point on a quadratic curve
- * @param y1 The y-coordinate of the control point on a quadratic curve
- * @param x2 The x-coordinate of the end on a quadratic curve
- * @param y2 The y-coordinate of the end point on a quadratic curve
- */
- public void quadTo(float x1, float y1, float x2, float y2) {
- isSimplePath = false;
- native_quadTo(mNativePath, x1, y1, x2, y2);
- }
由注解可以看出来quadTo(float x1, float y1, float x2, float y2)的四个参数分别是控制点的x,y坐标,结束点的x,y坐标,少了一个开始点呀!不要着急,开始点是Path路径的上一次结束的点,如果你的Path没有绘制过路径,那么Path的最后一个点坐标就是(0,0)如果想自己定义起始点位置,就用Path.moveTo(float x, float y)即可。
但是每次都需要指定具体的控制点和结束点既麻烦又容易出错,那么就需要rQuadTo(float dx1, float dy1, float dx2, float dy2)出马了,rQuadTo跟quadTo的区别在于rQuadTo使用的是相对起始点的坐标,而不是具体的坐标点,举个例子,如下代码效果等价:
- //使用quadTo
- Path path=new Path();
- path.moveTo(100,100);
- path.quadTo(150,0,200,100);
- //使用rQuadTo
- Path path=new Path();
- path.moveTo(100,100);
- path.rQuadTo(50,-100,100,0);
此时画笔最后的落点都为(200,100)。
画波浪线的技术难点解决了那么如何让波浪动起来呢,想动起来肯定需要波浪在水平方向移动,那么我们需要画一个很长很长的波浪让他移动,这样就实现了上下起伏效果,但是这样需要画无数多条贝塞尔曲线,肯定不行,这时就用到万能的数学理论——周期函数了,如果我们绘制两个周期的贝塞尔曲线,每次只让它显示一个周期,然后等第二周期显示结束的时候再从头开始,这样就造成了无限周期的假象,如下图
初始位置为1,向右前进,当走到2位置的时候重置成3的位置,即1原始的位置,如此往复就成了绵绵不绝的波浪了
绵延原理
做成效果如下:黄色区域就是要显示的区域,蓝色竖线是波浪线两个周期的总长度
连绵不绝的波浪线
2)、不规则的文字
我们可以看到圆球里的“贴”字在波浪区域显示的是白色,波浪区域之外显示的是蓝色,Android并不支持给文字部分区域着色的功能,那么我们只能靠控制显示区域让文字只显示特定形状,强大的Canvas正好有画布裁剪功能,通过裁剪画布就能控制绘制区域,画布的裁剪可以用Canvas.clipPath(Path path)实现,传入一个闭合的Path既可以随心所欲裁剪画布,裁剪示意图如下
裁剪文字
利用波浪形闭合路径讲画布裁剪成波浪形,那么在此接下来的Canvas绘制操的内容只能在这个波浪形区域里显示,这样就解决了文字的部分区域显示问题。那么接下来我们只用在相同位置绘制相同字体、字号不同色的文字即可实现一个文字显示两种颜色了(注意:实际操作的时候,被裁剪的文字要盖在未被裁减的文字的上边,即先在画布裁剪之前绘制蓝色的“贴”字,然后再裁剪画布再在裁剪后的画布上绘制白色的“贴”)
3)、控件显示部分限制成圆形
经过2)的分析,将显示部分限制在圆形区域里不是易如反掌吗,使用一个圆形的Path裁剪画布即可。感兴趣的同学也可以尝试BitmapShader或者Xfermode来将显示区域变成圆形
好了,最主要的步骤都分析完了,上一张图更直观地展示一下绘制流程
整体分析图
图中可以看出波浪形的闭合Path有两个作用,一个是负责裁剪画布,一个是负责绘制蓝色,其实只用第一个功能即可,此处只是方便分解步骤。
二、代码实现
文章只贴出主要代码,完整代码文末提供链接
既然是自定义控件,那就要有通用性比如下边的效果:
各种颜色的球球
loading小球需文字和颜色都可以改变,所以我们要给自己的控件添加这两个属性。首先在“res/values/”路径下新建一个attrs.xml文件,在里边定义如下属性:
- <declare-styleable name="Wave">
- <attr name="color" format="color"/>
- <attr name="text" format="string"/>
- </declare-styleable>
接下来开始自定义View
复写三个构造函数,将单参数和双参数的构造函数的super方法都改为this,保证无论调用哪个构造方法都会跳到三个参数的构造方法中,这样就可以偷懒只用在三个参数的构造方法里初始化各种参数了
- public class Wave extends View {
- public Wave(Context context) {
- this(context,null);
- }
- public Wave(Context context, AttributeSet attrs) {
- this(context, attrs,0);
- }
- public Wave(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- //初始化参数
- init(context,attrs);
- }
- }
接下来是初始化函数,在此处我们获取到自定义的颜色及文字参数,并初始化各种画笔,代码比较简单,看注释内容即可
- private void init(Context context, AttributeSet attrs) {
- //获取自定义参数值
- TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.Wave);
- //自定义颜色和文字
- color = array.getColor(R.styleable.Wave_color, Color.rgb(41, 163, 254));
- text = array.getString(R.styleable.Wave_text);
- array.recycle();
- //图形及路径填充画笔(抗锯齿、填充、防抖动)
- mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- mPaint.setStyle(Paint.Style.FILL);
- mPaint.setColor(color);
- mPaint.setDither(true);
- //文字画笔(抗锯齿、白色、粗体)
- textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- textPaint.setColor(Color.WHITE);
- textPaint.setTypeface(Typeface.DEFAULT_BOLD);
- //闭合波浪路径
- path = new Path();
- }
接下来是生成波浪线的方法,示意图如下:
波浪生成原理
将Path起点移动到最左边粉色点处,然后绘制两个周期的长度的波形(一上一下是一个周期),每个周期在x轴的跨度为此控件的宽度控制点距波形的轴线的绝对高度是整个控件的3/20,当然想让波形波动幅度大的话这个比例可以随意调整,接下来就用前边讲到的rQuadTo( )来生成闭合的波浪图形,其中mWidth为控件的宽度,mHeight为控件的高度
- private Path getActionPath(float percent) {
- Path path = new Path();
- int x = -mWidth;
- //当前x点坐标(根据动画进度水平推移,一个动画周期推移的距离为一个周期的波长)
- x += percent * mWidth;
- //波形的起点
- path.moveTo(x, mHeight / 2);
- //控制点的相对宽度
- int quadWidth = mWidth / 4;
- //控制点的相对高度
- int quadHeight = mHeight / 20 * 3;
- //第一个周期波形
- path.rQuadTo(quadWidth, quadHeight, quadWidth * 2, 0);
- path.rQuadTo(quadWidth, -quadHeight, quadWidth * 2, 0);
- //第二个周期波形
- path.rQuadTo(quadWidth, quadHeight, quadWidth * 2, 0);
- path.rQuadTo(quadWidth, -quadHeight, quadWidth * 2, 0);
- //右侧的直线
- path.lineTo(x + mWidth * 2, mHeight);
- //下边的直线
- path.lineTo(x, mHeight);
- //自动闭合补出左边的直线
- path.close();
- return path;
- }
上边代码所表示的闭合路径如下图
闭合的波浪图形
接下来就是重头戏onDraw了
- @Override
- protected void onDraw(Canvas canvas) {
- //底部的字
- textPaint.setColor(color);
- drawCenterText(canvas, textPaint, text);
- //上层的字
- textPaint.setColor(Color.WHITE);
- //生成闭合波浪路径
- path = getActionPath(currentPercent);
- canvas.clipPath(path);
- //裁剪成圆形
- canvas.drawCircle(mWidth / 2, mHeight / 2, mWidth / 2, mPaint);
- drawCenterText(canvas, textPaint, text);
- }
这里绘制思路是:在canvas上绘制蓝色的文字 ——>将画布裁剪成波浪形 ——>在波浪形画布上绘制圆 ——>在波浪形画布上绘制文字,这里一定要注意绘制顺序,先绘制的在下部,后绘制的在上部。
细心的朋友一定看到了一个函数drawCenterText(canvas, textPaint, text)没错,这个函数就是讲文字绘于控件正中心的方法。有的读者可能一直在使用Canvas.drawText( String text, float x, float y, Paint paint) 这个方法,但是参数中的(x,y)到底是哪个坐标呢,是文字左上角的点的坐标吗?不是的,接下来我们用代码验证一下这个(x,y)到底在文字的哪个部位
- canvas.drawText(text,600,200,textPaint);
- canvas.drawCircle(600,200,3,paint);
- canvas.translate(600, 200);
- Rect bgRect=new Rect(0,0,1000,400);
- canvas.drawRect(bgRect,bgPaint);
- Rect textBound=new Rect();
- textPaint.getTextBounds(text,0,text.length(),textBound);
- paint.setColor(Color.RED);
- canvas.drawRect(textBound,paint);
- Paint.FontMetrics metrics=textPaint.getFontMetrics();
- paint.setColor(Color.RED);
- // ascent 橙色
- paint.setColor(Color.rgb(255,126,0));
- canvas.drawLine(0, metrics.ascent, 500,metrics.ascent, paint);
- // descent
- paint.setColor(Color.rgb(255,0,234));
- canvas.drawLine(0, metrics.descent, 500, metrics.descent, paint);
- // top
- paint.setColor(Color.DKGRAY);
- canvas.drawLine(0, metrics.top, 500, metrics.top, paint);
- // bottom
- paint.setColor(Color.GREEN);
- canvas.drawLine(0, metrics.bottom, 500, metrics.bottom, paint);
首先是在画布的(600,200)处画上文字,为了方便观察(600,200)在文字的什么部位,我在(600,200)处画了一个半径3像素的圆圈。然后平移画布到(600,200)的地方然后依次画出了文字的边框图以及FontMetrics信息里的top、ascent、descent、bottom信息
我把运行结果截图做了处理,方便大家看
文字的各个边界
从结果看(600,200)那个蓝色的点并不是在文字的左上角,而是左下角,这个点所在的y坐标即是大家常说的BaseLine的位置,那现在这个函数Canvas.drawText( String text, float x, float y, Paint paint)就可以理解为——将文字的基准点放在(x,y)处,那么这个基准点可以改变吗?答案是肯定的,可以通过绘制文字的画笔的setTextAlign(Align align)方法设置为Paint.Align.CENTER或者Paint.Align.RIGHT,如果不设置的话默认是Paint.Align.LEFT。读者朋友们有兴趣的话可以试试设置成CENTER之后(600,200)的蓝圈圈是不是跑到了文字的中部呢?从上图我们也可以看出,整个文字是介于FontMetrics.top和FontMetrics.bottom之间。
好了,贴上文字居中的代码,相信认真看上边那段话的朋友一定能轻松读懂
- private void drawCenterText(Canvas canvas, Paint textPaint, String text) {
- Rect rect = new Rect(0, 0, mWidth, mHeight);
- textPaint.setTextAlign(Paint.Align.CENTER);
- Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
- //文字框最高点距离baseline的距离(负数)
- float top = fontMetrics.top;
- //文字框最低点距离baseline的距离(正数)
- float bottom = fontMetrics.bottom;
- int centerY = (int) (rect.centerY() - top / 2 - bottom / 2);
- canvas.drawText(text, rect.centerX(), centerY, textPaint);
- }
分析好上边的代码 我们就能绘制出一个静态的小球了,动画既然要动,肯定就像汽车一样需要一个"引擎",在上面说到的绘制波浪路径的函数中我们忽略了getActionPath(float percent)的参数percent,这个参数即是当前动画的进度,那么我们如何来制造这个进度呢?需要怎样把这个动画“引擎”点燃呢。我们可以通过各种手段计时,生成一个计时Thread或者自己写一个Handler等等,只要能均匀的生成进度即可。
本文中用到一个巧妙的定时器ValueAnimator 大家常说的属性动画ObjectAnimator就是它的一个子类,使用它来作为动画的引擎再方便不过了,从字面翻译"ValueAnimator"那就是“值动画者”直译虽然low但是恰恰更好理解,就是让数值动起来,从什么值动到什么值呢?
- ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
这句话就是定义一个值从0变化到1的一个animator,我们的percent值就是从0变化到1的中间过程值,那么怎么得到这个过程值呢?——监听器!对!
- animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(ValueAnimator animation) {
- float percent = animation.getAnimatedFraction();
- }
- });
那么数值从0变到1需要多久呢?怎么能无限重复呢?重复的时候是重头开始还是反转进行呢?别急下面三句话就是让动画无限重复,每次从头开始,一个周期1000毫秒
- animator.setDuration(1000);
- animator.setRepeatCount(ValueAnimator.INFINITE);
- animator.setRepeatMode(ValueAnimator.RESTART);
好了,引擎设置好了,发动
animator.start();
上效果
鬼畜版
WTF!这是什么鬼,为什么鬼畜地慢几拍?
打印出来横坐标看看
- 07-09 18:18:47.308 E/Jcs: getActionPath: -21
- 07-09 18:18:47.326 E/Jcs: getActionPath: -15
- 07-09 18:18:47.342 E/Jcs: getActionPath: -10
- 07-09 18:18:47.359 E/Jcs: getActionPath: -5
- 07-09 18:18:47.375 E/Jcs: getActionPath: -2
- 07-09 18:18:47.392 E/Jcs: getActionPath: 0
- 07-09 18:18:47.409 E/Jcs: getActionPath: 0
最后几拍的数值差好像不太对呀!拍拍脑门突然一想,我的动画不均匀是忘记设置一个均匀的插值器了!哎!
- animator.setInterpolator(new LinearInterpolator());
补上一个线性插值器,整个世界都顺畅了
百度Loading小球Github源码
三、结语
第一次写文章,不免有些疏漏之处,望多多指教!后续我会不定期更新新的内容,争取把写文章当成自己生活的一部分。
后记(2017年7月27日15:02:39)
有不少读者问到关于小球和边缘锯齿的问题,我分别用如下方式实现loading小球
1、Canvas的clip方式限制波浪边界(本文提到的方法)
2、使用Xfermode方式限制波浪和圆形的边界
3、用Xfermode方式限制白色文字,用shader方式限制圆形的边界
下边是效果预览图,代码已经提交到github上了,讲解部分尽快补到此文中
三种方式对比