目录
写在前面
一、Canvas详解
1.1、绘制
1.2、变换
1.3、状态保存和恢复
二、粒子特效
先来看下今天要实现什么效果,来,上图:
看了还OK吧,等下就一起来学习下如何实现它。上一篇说了图层混合模式和滤镜效果的实现——《AndroidUI之Paint滤镜&XFERMODE解析》,今天继续来玩UI,不,应该是最近一段时间都玩UI,今天我们来看看Canvas有哪些高级的用法呢?
基础概念:画布,通过画笔绘制几何图形、文本、路径和位图等
常用API类型:常用API分为绘制、变换、状态保存和恢复
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//绘制Bitmap
canvas.drawBitmap(mBitmap,0,0,mPaint);
//画线
canvas.drawLine(350,200,850,200,mPaint);
//绘制路径
Path path = new Path();
path.addCircle(150,550,100, Path.Direction.CW);
canvas.drawPath(path,mPaint);
//画点
canvas.drawPoint(500,550,mPaint);
//绘制文本
canvas.drawText("Android高级开发工程师",350,700,mTextPaint);
}
运行结果如下所示:
关于绘制的相关API,在上两篇文章中我也简单的写过一些了,这里就不再细说了,大家可以根据自身需要,有选择性的学习。
注意:对于绘制的API远不止上面这些,其实还有很多,大家可以去查看官方文档或者打开Canvas的源码对照注释进行学习。
关于位置和形状等的变换常用的API如下图所示,下面我们通过代码一个一个的来看:
①、平移操作
首先绘制一个矩形,然后进行平移操作,在X轴和Y轴分别平移50个像素,平移之后再次绘制一个不同颜色的矩形:
//平移操作
canvas.drawRect(0,0, 400, 400, mPaint);
canvas.translate(50, 50);
mPaint.setColor(Color.GRAY);
canvas.drawRect(0,0, 400, 400, mPaint);
canvas.drawLine(0, 0, 450,450, mPaint);
效果如下图所示:
从图中结合代码可以看出,这里是将整个画布进行了平移,因为第二个矩形的起始点我们传入的是0,不难发现这个起始点是在移动50个像素之后的位置,从上图中绘制的线的位置也可以很容易发现。
这里我们通过一张图来了解一下坐标系,下图分别是未平移和平移之后的坐标原点,这样应该就很好理解了:
②、缩放操作
缩放操作同样针对的是整个画布,scale()方法有两个重载,先来看第一种两个参数的,入参分别传入x轴和y轴上的缩放比例:
//缩放操作
canvas.drawRect(200, 200, 700, 700, mPaint);
canvas.scale(0.5f, 0.5f);
mPaint.setColor(Color.GRAY);
canvas.drawRect(200, 200, 700, 700, mPaint);
结果如下:
第二种是四个参数,前两个入参同样是x轴和y轴的缩放比例,后面两个参数是x轴和y轴上平移的像素值,这个方法实际上是进行了两步操作,先是进行了translate平移操作,再进行scale缩放操作,最后再反向translate:
canvas.drawRect(200, 200, 700, 700, mPaint);
//先translate(px, py),再scale(sx, sy),再反向translate
canvas.scale(0.5f, 0.5f, 200, 200);
mPaint.setColor(Color.GRAY);
canvas.drawRect(200, 200, 700, 700, mPaint);
结果如下图所示:
上面的那一行代码就等同于下面这三行代码:
//canvas.scale(0.5f, 0.5f, 200, 200);等同于这一行
canvas.translate(200, 200);
canvas.scale(0.5f, 0.5f);
canvas.translate(-200, -200);
③、旋转操作
旋转操作rotate,入参可以传入一个角度,将画布进行旋转,默认是顺时针方向旋转,注意如果将画布进行了平移操作,那么对应的旋转中心也就是平移之后的:
//旋转操作
//canvas.translate(50, 50);
canvas.drawRect(0, 0, 700, 700, mPaint);
canvas.rotate(45);
mPaint.setColor(Color.GRAY);
canvas.drawRect(0, 0, 700, 700, mPaint);
结果如下图所示:
rotate()还有一个重载的方法,三个入参,第一个是旋转角度,第二个是旋转中心的x坐标,第三个是旋转中心的y坐标:
//将旋转中心定位到矩形的中心点
canvas.drawRect(300, 300, 800, 800, mPaint);
canvas.rotate(45, 550, 550); //px, py表示旋转中心的坐标
mPaint.setColor(Color.GRAY);
canvas.drawRect(300, 300, 800, 800, mPaint);
结果如下图所示:
④、倾斜操作
skew()方法有两个参数,分别表示x轴和y轴的tan值,即直角三角形中的对边比邻边的值,tan45°=1,skew(1,0)表示在x轴方向倾斜45度,skew(0,1)表示在y轴倾斜45度:
//倾斜操作
canvas.drawRect(0,0, 400, 400, mPaint);
canvas.skew(1, 0); //在X方向倾斜45度,即:将Y轴逆时针旋转45
// canvas.skew(0, 1); //在y方向倾斜45度,即:将X轴顺时针旋转45
mPaint.setColor(Color.GRAY);
canvas.drawRect(0, 0, 400, 400, mPaint);
结果如下图所示:
⑤、切割操作
clip()它可以切割矩形也可以切割路径,这里以切割矩形为例:
//切割操作
canvas.drawRect(200, 200,700, 700, mPaint);
mPaint.setColor(Color.GRAY);
canvas.drawRect(200, 800,700, 1300, mPaint);
canvas.clipRect(200, 200,700, 700); //画布被裁剪
canvas.drawCircle(100,100, 100,mPaint); //坐标超出裁剪区域,无法绘制
canvas.drawCircle(300, 300, 100, mPaint); //坐标区域在裁剪范围内,绘制成功
这里我们绘制了两个矩形,并对第一个矩形的区域进行了切割,然后绘制了两个圆,结果你会发现,第一个圆在切割范围外部,所以无法绘制,第二个圆在切割范围内部可以绘制,如下图所示:
裁剪除了clipX()方法,还有clipOutX()方法,clipOut可以进行反向裁剪,同样的代码我们换成clipOut来看一下是什么效果:
canvas.drawRect(200, 200, 700, 700, mPaint);
mPaint.setColor(Color.GRAY);
canvas.drawRect(200, 800, 700, 1300, mPaint);
canvas.clipOutRect(200, 200, 700, 700); //画布裁剪外的区域
canvas.drawCircle(100, 100, 100, mPaint); //坐标区域在裁剪范围内,绘制成功
canvas.drawCircle(300, 300, 100, mPaint);//坐标超出裁剪区域,无法绘制
其实结果也是显而易见的,就是和上面那一种情况恰好相反的,因为使用的是反向裁剪,所以此时有效的作用域变成了红色矩形以外的区域,所以绘制的两个圆的显示情况恰好反过来了:
⑥、矩阵操作
对于矩阵操作这里用到的是Matrix类,这个类中封装了一些API供我们使用,通过setXXX()方法,我们同样的可以实现Canvas的平移、旋转、缩放、倾斜等的操作:
//矩阵操作
canvas.drawRect(0, 0, 700, 700, mPaint);
Matrix matrix = new Matrix();
//matrix.setTranslate(50, 50); //平移
//matrix.setRotate(45); //旋转
//matrix.setScale(0.5f, 0.5f); //缩放
matrix.setSkew(1,0); //倾斜
canvas.setMatrix(matrix);
mPaint.setColor(Color.GRAY);
canvas.drawRect(0, 0, 700, 700, mPaint);
相应的效果如下所示:
首先我们在200的位置绘制一个矩形,然后移动画布到50的位置,更改画笔颜色再次绘制一个矩形:
canvas.drawRect(200, 200, 700, 700, mPaint);
canvas.translate(50,50);
mPaint.setColor(Color.GRAY);
canvas.drawRect(0,0,500,500, mPaint);
可以看到灰色矩形的原点是在(50,50)的位置,如果我们想让它再次从(0,0)这个点开始绘制该怎么办呢?有人说你再把坐标移回去,嗯这样当然可以,不过canvas中有相应的api可以做到,咱们就没必要自己瞎折腾了。
首先你可以调用canvas.save()方法进行状态的保存,在这之后无需关心后续你做了哪些操作,只要在你想要恢复这个状态的时候调用canvas.restore()方法就可以恢复到之前的状态,来看效果:
canvas.drawRect(200, 200, 700, 700, mPaint);
canvas.save();
canvas.translate(50,50);
canvas.restore();
mPaint.setColor(Color.GRAY);
canvas.drawRect(0,0,500,500, mPaint);
save()和restore()可以多次调用,这两者是一 一对应的关系,也就是说restore()恢复的状态是跟你调用了几次save()有关的,如果你有多个save(),那么想要回到最初的状态就要多次调用restore()。实际上canvas内部维护了一个状态栈,我们当做栈这种数据结构来看就很容易理解了,后进先出嘛,restore()一次就是把最顶层的状态进行了出栈的操作,我们通过一段代码来看一下:
Log.e("onDraw", ""+canvas.getSaveCount());
canvas.drawRect(200, 200, 700, 700, mPaint);
canvas.save();
Log.e("onDraw", "" + canvas.getSaveCount());
canvas.translate(50, 50);
mPaint.setColor(Color.GRAY);
canvas.drawRect(0, 0, 500, 500, mPaint);
canvas.save();
Log.e("onDraw", ""+canvas.getSaveCount());
canvas.translate(50, 50);
mPaint.setColor(Color.GREEN);
canvas.drawRect(0, 0, 500, 500, mPaint);
canvas.restore();
Log.e("onDraw", "" + canvas.getSaveCount());
canvas.restore();
Log.e("onDraw", ""+canvas.getSaveCount());
canvas.drawLine(0, 0, 400, 500, mPaint);
效果如下图所示:
从日志中也能看出这个入栈出栈的过程:
注意:不能在调用restore()方法的时候没有对应的sava(),也就是说不能调用超出了实际的范围,它的条件就是canvas.getSaveCount()>1,否则会报错。
接着来看,还有一个restoreToCount()方法,可以将画布恢复到指定的某一个状态下,来看如下代码:
Log.e("onDraw", "" + canvas.getSaveCount());
canvas.drawRect(200, 200, 700, 700, mPaint);
int state = canvas.save();
Log.e("onDraw", "" + canvas.getSaveCount());
canvas.translate(50, 50);
mPaint.setColor(Color.GRAY);
canvas.drawRect(0, 0, 500, 500, mPaint);
canvas.save();
Log.e("onDraw", "" + canvas.getSaveCount());
canvas.translate(50, 50);
mPaint.setColor(Color.GREEN);
canvas.drawRect(0, 0, 500, 500, mPaint);
// canvas.restore();
Log.e("onDraw", "" + canvas.getSaveCount());
// canvas.restore();
canvas.restoreToCount(state);
Log.e("onDraw", "" + canvas.getSaveCount());
canvas.drawLine(0, 0, 400, 500, mPaint);
运行的结果如下图所示:
可以看到最后绿色画笔画线的时候是从屏幕最开始的左上角原点开始绘制的,因为在代码中我们直接调用了restoreToCount让它返回到了最开始的状态,它会把状态栈中这个指定状态之上的所有状态都移除出栈。
除了以上的这些方法,我们还可以使用canvas.saveLayer()来保存状态,它同样返回的是一个int类型的值,保存之后可以继续在图层中做自己的事情,做完之后,可以通过canvas.restoreToCount来恢复图层,之前在介绍图层混合的时候我们有使用过它,不知道大家还记得不,它表示的是离屏绘制。它是新创建一个图层,将这两个方法之间的这些操作先绘制到这个图层之上,然后再将这个图层绘制到Canvas之上。这里需要注意的是saveLayer方法可以指定图层的大小,下面来看一段具体的代码:
canvas.drawRect(200, 200, 700, 700, mPaint);
int layerId = canvas.saveLayer(0, 0, 700, 700, mPaint);
mPaint.setColor(Color.GRAY);
Matrix matrix = new Matrix();
matrix.setTranslate(100, 100);
canvas.setMatrix(matrix);
canvas.drawRect(0, 0, 700, 700, mPaint); //由于平移操作,导致绘制的矩形超出了图层的大小,所以绘制不完全
canvas.restoreToCount(layerId);
mPaint.setColor(Color.RED);
canvas.drawRect(0, 0, 100, 100, mPaint);
结果如下图所示:
从图中大家结合代码可以看出,最开始绘制的是这个大的红色矩形,之后使用saveLayer并且设定了图层的大小,然后将画布平移了100个像素,再次绘制设定大小的矩形之后,会发现这块灰色矩形实际上没有绘制完全,最后恢复原状态之后再次绘制了一个小的红色的矩形,也就是左上角那块,正好是100x100。
OK,到这里,今天的基本知识点咱们就说的差不多了,接下来我们来做一个小案例。
首先来看效果:
分析:基本思路就是把这张图转成粒子,然后实现它的爆炸效果,就是将转换后的粒子进行位置的移动,类似于自由落体运动。
首先我们肯定是要准备一张图片,我这里还是使用的之前准备的俺家颖宝的图片,然后先来看下面这几行代码:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ying); //解析得到一张Bitmap对象
bitmap.getWidth(); //宽,水平方向有多个像素点
bitmap.getHeight(); //高,垂直方向有多少个像素点
int pixel = bitmap.getPixel(0, 0); //此方法返回的是图片在当前位置像素的颜色值,入参是表示水平方向和垂直方向像素点的位置
在代码中我们肯定要首先获取到Bitmap对象,然后可以获取Bitmap对象的宽和高,也就是Bitmap在水平方向和垂直方向上的像素点的个数,最后我们可以通过bitmap的getPixel(x,y)方法来获取图片在当前位置像素点的颜色值,入参x,y表示水平方向和垂直方向像素点的位置,这样我们就可以获取每一张图片每一个像素点的颜色值,所以接下来我们可以把每一个像素点封装成一个粒子对象。
我们给粒子对象定义如下几个属性:
public class Ball {
public int color; //图片像素点颜色值
public float x; //粒子圆心坐标x
public float y; //粒子圆心坐标y
public float r; //粒子半径
public float vX;//粒子运动水平方向速度
public float vY;//粒子运动垂直方向速度
public float aX;//粒子运动水平方向加速度
public float aY;//粒子运动垂直方向加速度
}
OK,接着我们就可以来定义粒子爆炸的View了,首先来初始化画笔,Bitmap,粒子的直径,还有粒子集合,因为我们是把这张Bitmap给分解了,所以最后绘制在屏幕上的不是一张图像了,而是由一个个的小圆组成的:
private Paint mPaint;
private Bitmap mBitmap;
private float d = 3;//粒子直径
private ValueAnimator mAnimator;
private List mBalls = new ArrayList<>();
接着遍历Bitmap的宽和高,获取每个位置上的颜色值,并且初始化粒子对象,为其设置相应的属性比如圆心坐标、半径、速度和加速度等等,将构建完成的粒子对象添加到粒子集合中:
setLayerType(View.LAYER_TYPE_SOFTWARE, null); //关闭硬件加速
mPaint = new Paint();
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.liying);
for (int i = 0; i < mBitmap.getWidth(); i++) {
for (int j = 0; j < mBitmap.getHeight(); j++) {
Ball ball = new Ball();
ball.color = mBitmap.getPixel(i,j);
ball.x = i * d + d/ 2;
ball.y = j * d + d/ 2;
ball.r = d / 2;
//速度(-20,20)
ball.vX = (float) (Math.pow(-1, Math.ceil(Math.random() * 1000)) * 20 * Math.random());
ball.vY = rangInt(-15, 35);
//加速度
ball.aX = 0;
ball.aY = 0.98f;
mBalls.add(ball);
}
}
接着初始化属性动画,为其设置执行时间、插值器以及监听执行的回调,在回调监听中刷新粒子的位置:
mAnimator = ValueAnimator.ofFloat(0,1);
mAnimator.setRepeatCount(-1);
mAnimator.setDuration(2000);
mAnimator.setInterpolator(new LinearInterpolator());
mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
updateBall();
invalidate();
}
});
在点击这个View的时候执行动画,也就是需要在onTouchEvent的ACTION_DOWN事件中开启执行动画的操作:
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN){
//执行动画
mAnimator.start();
}
return super.onTouchEvent(event);
}
完整代码如下:
public class SplitView extends View {
private Paint mPaint;
private Bitmap mBitmap;
private float d = 3;//粒子直径
private ValueAnimator mAnimator;
private List mBalls = new ArrayList<>();
public SplitView(Context context) {
this(context, null);
}
public SplitView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SplitView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init(){
setLayerType(View.LAYER_TYPE_SOFTWARE, null); //关闭硬件加速
mPaint = new Paint();
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.liying);
for (int i = 0; i < mBitmap.getWidth(); i++) {
for (int j = 0; j < mBitmap.getHeight(); j++) {
Ball ball = new Ball();
ball.color = mBitmap.getPixel(i,j);
ball.x = i * d + d/ 2;
ball.y = j * d + d/ 2;
ball.r = d / 2;
//速度(-20,20)
ball.vX = (float) (Math.pow(-1, Math.ceil(Math.random() * 1000)) * 20 * Math.random());
ball.vY = rangInt(-15, 35);
//加速度
ball.aX = 0;
ball.aY = 0.98f;
mBalls.add(ball);
}
}
mAnimator = ValueAnimator.ofFloat(0,1);
mAnimator.setRepeatCount(-1);
mAnimator.setDuration(2000);
mAnimator.setInterpolator(new LinearInterpolator());
mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
updateBall();
invalidate();
}
});
}
private int rangInt(int i, int j) {
int max = Math.max(i, j);
int min = Math.min(i, j) - 1;
//在0到(max - min)范围内变化,取大于x的最小整数 再随机
return (int) (min + Math.ceil(Math.random() * (max - min)));
}
private void updateBall() {
//更新粒子的位置
for (Ball ball : mBalls) {
ball.x += ball.vX;
ball.y += ball.vY;
ball.vX += ball.aX;
ball.vY += ball.aY;
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(350,100);
for (Ball ball : mBalls) {
mPaint.setColor(ball.color);
canvas.drawCircle(ball.x, ball.y, ball.r, mPaint);
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN){
//执行动画
mAnimator.start();
}
return super.onTouchEvent(event);
}
}
到这里,我们的这个小案例也就介绍完了,那么今天的内容也就这么多吧,下次继续!各位再见!
祝:工作顺利!