想法由来
大家肯定都在PC端的12306网站上买过票吧,最近的这两天又去买票的时候留意到这个刷新loading,觉得还是挺好看的,所以就想动手实现下,顺便把自定义view和动画的知识稍微再熟悉一遍。
原12306的刷新图:
实现的效果图如下:
自定义view的代码如下(暂且就叫PassView吧)
package com.example.yanxu.loading.view;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.LinearInterpolator;
import com.example.yanxu.loading.R;
/**
* Created by chen on 2018/12/12.
* 仿PC端12306的刷新loading动画的自定义view
*/
public class PassView extends View {
private int mColor = Color.RED;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private int mWidth, mHeight;
private float mRadius = 0f;
private ValueAnimator animator;
private int theCircle = -1;//当前哪个圆球变化
public PassView(Context context) {
this(context, null);
}
public PassView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PassView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}
//初始化
private void init(AttributeSet attrs) {
TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.PassView);
if (typedArray != null) {
mColor = typedArray.getColor(R.styleable.PassView_pass_color, Color.RED);
mRadius = typedArray.getDimension(R.styleable.PassView_pass_radius, 0);
typedArray.recycle();
}
mWidth = (int) (2 * mRadius);
mHeight = (int) (2 * mRadius);
}
/**
* 重写onMeasure方法,解决wrap_content问题,因为:
* 直接继承View的自定义控件需要重写onMeasure方法并设置wrap_content时的自身大小,否则在布局中使用wrap_content就相当于使用match_parent
* 这里的view默认宽高为了方便直接指定为小圆的直径
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(mWidth, mHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(mWidth, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, mHeight);
}
}
/**
* 重写onDraw方法实现自己的想要的效果,同时处理padding问题,因为:
* 绘制的时候需要考虑到View四周的空白,即padding,否则计算会有偏差
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int pLeft = getPaddingLeft();
int pRight = getPaddingRight();
int pTop = getPaddingTop();
int pBottom = getPaddingBottom();
int width = getWidth() - pLeft - pRight;
int height = getHeight() - pTop - pBottom;
int mHLength = 150;//大圆的半径
mPaint.setColor(mColor);
mPaint.setTextSize(50);
//1、动画开启前,theCircle的初始值为-1,所以初始化时只走canvas.drawCircle()方法,即所有位置均为蓝色
//2、动画开启后,theCircle=0时不改变颜色,即所有位置均为蓝色
//3、继续,theCircle=1、i=2时,第一个位置依旧为蓝色,第二个及剩余其他位置为灰色
//4、继续,theCircle=2,i=3时,第一、二个位置依旧为蓝色,第三个及剩余其他位置为灰色
//5、......
//6、theCircle=11,i=12时,第一到十一个位置全部为蓝色,剩余的第十二个为灰色
//7、直到,theCircle=0,循环继续
//注:思路重点为,Paint使用的是同一个,所以在重新setColor()前的颜色是一样的,setColor()后的颜色是另一样的
for (int i = 1; i <= 12; i++) {
if (theCircle + 1 == i && theCircle != 0) {
mPaint.setColor(getResources().getColor(R.color.colorPassGray));
}
canvas.drawCircle(width / 2 + (float) Math.sin(Math.PI * (i - 1) / 6) * mHLength,
height / 2 - (float) Math.cos(Math.PI * (i - 1) / 6) * mHLength, mRadius, mPaint);
//Math.PI即为π,等于180度
}
}
//开启动画
public void start() {
if (animator != null)
animator.cancel();
animator = ValueAnimator.ofInt(0, 12);
animator.setDuration(2000);//动画时长
animator.setRepeatMode(ValueAnimator.RESTART);//动画的重复次数
animator.setRepeatCount(ValueAnimator.INFINITE);//动画的重复模式
animator.setInterpolator(new LinearInterpolator());//线性插值器:匀速动画
//监听动画过程
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//获取当前动画的进度值,此处的theCircle值为(0、1、2...11、0)
theCircle = ((int) animation.getAnimatedValue() % 12);
invalidate();
}
});
animator.start();
}
}
在说具体的实现思路前,这里先回顾下自定义view的流程:
对于继承自View的自定义view而言,首先我们肯定是要重写onDraw()方法通过代码逻辑来实现具体的效果,如果想要支持wrap_content,那么还需要重写onMeasure()方法,既然是自定义view,那么我们免不了需要添加自定义的属性,概括下来具体流程如下:
- 第一步,在values目录下创建自定义属性的XML(如attrs.xml),并声明自定义属性集合,本例为“PassView”
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="PassView">
<attr name="pass_color" format="color" />
<attr name="pass_radius" format="dimension" />
</declare-styleable>
</resources>
- 第二步,在view的构造方法中去解析自定义属性的值
//初始化
private void init(AttributeSet attrs) {
TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.PassView);
if (typedArray != null) {
mColor = typedArray.getColor(R.styleable.PassView_pass_color, Color.RED);
mRadius = typedArray.getDimension(R.styleable.PassView_pass_radius, 0);
typedArray.recycle();
}
mWidth = (int) (2 * mRadius);
mHeight = (int) (2 * mRadius);
}
- 第三步,在布局文件中使用自定义的view
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.MainActivity">
<com.example.yanxu.loading.view.PassView
android:id="@+id/pv_test"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible"
app:pass_color="@color/colorPassLight"
app:pass_radius="5dp" />
</LinearLayout>
说完了自定义view的流程,紧接着再简单说下我的思路,主要的涉及到核心代码的地方都加了注释,本身逻辑也不是特别复杂,相信大家应该都能看的明白。
- 首先是先画出十二个小圆球;
- 实现让当前小圆球从灰色变成蓝色的状态;
- 依次循环绘画,实现动画效果。
1、首先是先画出十二个小圆球
如图,十二个小圆球均匀分布在大圆的周边,我们定义大圆的中心点为该控件的中心点(width / 2 , height / 2),则图中那个小圆球的坐标为如图手写所示,其他几个小圆球的坐标只要改变为对应的角度即可画出所有的小圆球:
for (int i = 1; i <= 12; i++) {
canvas.drawCircle(width / 2 + (float) Math.sin(Math.PI * (i - 1) / 6) * mHLength,
height / 2 - (float) Math.cos(Math.PI * (i - 1) / 6) * mHLength, mRadius, mPaint);
//Math.PI即为π,等于180度
}
2、实现让当前小圆球从灰色变成蓝色的状态
这里我们可以使用属性动画,让ValueAnimator对象的变化范围为ValueAnimator.ofInt(0, 12),匀速循环播放
animator = ValueAnimator.ofInt(0, 12);
animator.setDuration(2000);//动画时长
animator.setRepeatMode(ValueAnimator.RESTART);//动画的重复次数
animator.setRepeatCount(ValueAnimator.INFINITE);//动画的重复模式
animator.setInterpolator(new LinearInterpolator());//线性插值器:匀速动画
3、依次循环绘画,实现动画效果
这里通过监听动画过程,获取当前动画的进度值,即绘制到哪一个小圆球,通过监听动画过程,我们可得到此处的theCircle值的变化为(0、1、2…11、0)
//监听动画过程
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//获取当前动画的进度值,此处的theCircle值为(0、1、2...11、0)
theCircle = ((int) animation.getAnimatedValue() % 12);
invalidate();
}
});
然后我们进行依次绘制的过程,具体如下:
- 动画开启前,theCircle的初始值为-1,所以初始化时只走canvas.drawCircle()方法,即所有位置均为蓝色
- 动画开启后,theCircle=0时不改变颜色,即所有位置均为蓝色
- 继续,theCircle=1、i=2时,第一个位置为蓝色,第二个及剩余其他位置为灰色
- 继续,theCircle=2,i=3时,第一、二个位置为蓝色,第三个及剩余其他位置为灰色
- …
- theCircle=11,i=12时,第一到十一个位置全部为蓝色,剩余的第十二个为灰色
- 直到,theCircle=0,循环继续
注:思路重点为,Paint使用的是同一个,所以在重新setColor()前的颜色是一样的,setColor()后的颜色是另一样的
mPaint.setColor(mColor);
mPaint.setTextSize(50);
for (int i = 1; i <= 12; i++) {
if (theCircle + 1 == i && theCircle != 0) {
mPaint.setColor(getResources().getColor(R.color.colorPassGray));
}
canvas.drawCircle(width / 2 + (float) Math.sin(Math.PI * (i - 1) / 6) * mHLength,
height / 2 - (float) Math.cos(Math.PI * (i - 1) / 6) * mHLength, mRadius, mPaint);
//Math.PI即为π,等于180度
}
总结:以上就是我的具体实现方案以及代码逻辑,整个过程涉及到自定义view以及属性动画的使用,应该还有更好的实现方案,还望指正。个人觉得有些效果真的很有必要去亲自代码逻辑实现下,开始看到这个效果的时候觉得很简单,但是真正实现起来有些细节部分还是需要仔细想想的,而且在实现的过程中也是一个知识巩固的过程~
demo已经上传至github,需要的小伙伴可以看一下–>传送门