本文摘抄至 :株洲新城 IT教育 李赞红老师。非常感谢老师。想过摘抄一边的方式去让自己记住一些知识
第三章 使用Graphics2D 实现动态效果
3.1、概述
本章讲述如何如何通过绘制实现动态效果。动态包括两个方面。一是,让画面动起来。如实现游戏中的爆照效果,地球仪的自转和公转,小鸟翅膀的摆动,手表时针、分针、秒针的运转等。这一类动画可以通过周期性重画实现。二是,实现和用户的互动,用户通过手指在手机屏幕上移动。在绘制区绘制曲线、矩形、圆、文字等图案。如绘图软件就是这种类型。 在绘图过程中,双缓存技术是一项很重要的技术;一方面能,大大提高绘图效率;另一方面,可以实现绘图过程与结果分离,拥有身临其境的用户体验。理解和掌握双缓存技术的作用和意义是真正掌握 Graphics2D 的关键性因素之一。 上一章节(Graphics2D API ) 我们在 ImageView上绘制,这只是权宜之计。本章我们定义有个继承自 View的子类,重写 onDraw(),在该方法中绘制。当View显示时会回调 onDraw()方法,这也是 View的扩展方法。用于绘制组件的外观。基本结构如下:public class MyView extends View{
@Override
protected void onDraw(Canvas canvas) {
......
}
}
3.2、invalidate() 方法
View类定义了一组 invalidate()方法,该方法有好几个版本:- public void invalidate()
- public void invalidate(int l, int t, int r, int b)
- public void invalidate(Rect dirty)
- public void postInvalidate()
- public void postInvalidate(int left, int top, int right, int bottom)
public class BallMoveView extends View {
/**小球的水平位置*/
private int x ;
/**小球的垂直位置,固定为 100*/
private static final int Y= 100 ;
/**小球的半径*/
private static final int RADIUS = 30 ;
private Paint paint ;
private Paint paint2 ;
/**移动的方向*/
private boolean direction ;
public BallMoveView(Context context, AttributeSet attrs) {
super(context, attrs);
paint = new Paint() ;
paint.setAntiAlias(true) ;
paint.setStyle(Style.FILL) ;
paint.setColor(Color.RED);
paint2 = new Paint() ;
paint2.setAntiAlias(true) ;
paint2.setStyle(Style.STROKE) ;
paint2.setStrokeWidth(8);
paint2.setColor(Color.BLUE);
x = RADIUS ;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//回去组件的宽度
int width = this.getMeasuredWidth() ;
//根据 x,y的坐标画小球
canvas.drawCircle(x, Y, RADIUS, paint) ;
canvas.drawPoints(new float[]{30,100,width-RADIUS,100}, paint2) ;
//改变 x坐标,再 invalidate(),使小球动起来
if(x <= RADIUS){
direction = true ;
}
if(x >= width - RADIUS){
direction = false ;
}
x = direction ? x +5 : x-5 ;
}
}
我们定义了一个 BallMoveView类,继承自 View,并重写了 ondraw()方法,该方法适用于在组件上绘图的方法,同时,我们的定义了有两个参数的构造方法。如果在布局文件中定义了该组件,则会调用此构造方法来创建对象。 在 onDraw()方法中,画布 Canvas对象自动传入。显示在 View上的内容最终都必须绘制在 canvas对象上。我们I调用 canvas对象的 drawCricle()方法回一个圆,根据 Paint对象的定义,这是一个红色的实心圆。和另外用 drawPoints()方法绘制出来,小球的起始和终止的临界值的点。未来路判断小球的移动的方法,需要判断小球的当前位移,如果小球的位置 x 值小于等于小球的半径。说明小球已达到左边边界,讲标识变量 direction赋值为 true,小球即掉头向右移动;如果 x 的值大于等于组件宽度减去小球的半径,direction赋值为 false,小球将再次向左移动。getMeasuedWidht()方法用于获取组件的宽度,与之对应的是 getMeasuredHeigth()方法,用于获取组件的高度。 自定义组件在布局文件中的标签是组件的全限定名称,并且 View类的所有属性会继承下来,即使 BallMovaView组件没有定义任何属性,android:layout_width 和 android:layout_height 等由View定义的属性依然会有。 那么,小球是如何水平移动的呢 ? 在 onDraw()方法中,我们发现该方法的最后几行代码会根据 direction来修改成员变量 x 的值,如果 direction 为 true,x 累加 5;如果 direction 为 false,x 累减 5 。正如上面所说,我们在 Activity中恰恰是通过定时器周期性调用 invalidate()方法不断重绘组件。也就是不断调用 onDraw()方法。因为小球的位置由 x 来决定,onDraw()梅调用一次,x 的值就会变化一次,小球的位置自然也会跟着一起变化,最后形成了小球的移动效果。 BallMoveView ball = (BallMoveView) findViewById(R.id.ballview) ;
new Timer().schedule(new TimerTask() {
@Override
public void run() {
ball.postInvalidate() ;
}
//延时 2秒后,没有 50毫秒计时一次
}, 2000,50);
上面代码中,通过 Timer类定义一个计时器,延时 2000毫秒开始计时,每隔 50毫秒计时一次。定时任务 TimerTask其实就是一个子线程。所以,不能使用只能运行在 UI线程的 invalidate()方法,只能调用 postInvalidate()方法来重绘组件。 如图所示,小球的移动效果:
3.3、坐标转换
默认情况下,画布坐标的原点就是绘图区域的左上角,向左为负,向右为正;向上为负,向下为正。但是,通过 Canvas提供的方法可以对坐标进行转换。转换的方式主要有 4种:平移、旋转、缩放 和 拉斜 : public void translate(float dx, float dy)
坐标平移,在当前原点的基础上水平移动 dx 个距离,垂直移动 dy 个距离。正负发符决定方向。坐标原点改变之后,所有的坐标都是以新额原点为参照进行定位。
下面两段代码是等效的:
- canvas.drawPoint(10, 10, paint);
- canvas.translate(10, 10); canvas.drawPoint(0, 0, paint)
public void rotate(float degrees)
将画布的坐标以当前原点为中心旋转指定的角度,如果就哦啊度为正,则为顺时针旋转,否则为逆时针旋转
- public final void rotate(float degrees, float px, float py)
- 以点(px,py)为中心对画布坐标进行旋转 degrees度,为 正,表示顺时针;为负,表示逆时针
- public void scale(float sx, float sy)
- 缩放画布的坐标,sx,sy分别是 x 方向和 y 方向的缩放比例。小于 1 表示缩放,等于 1 表示不变,大于 1 表示放大。画布缩放后,绘制在画布上的图形也会等比例缩放。
- public final void scale(float sx, float sy, float px, float py)
- 以(px,py)为中心对画布进行缩放
- public void skew(float sx, float sy)
- 将画布分别在 x 方向和 y 方向斜拉一定的角度,sx 为 x 方向倾斜角度 tan值,sy 为 y 方向 倾斜的 tan 值,比如我们打算在 x 轴方向上倾斜 45度,则 tan 45 = 1 ,写成:canvas.skew(1,0)
坐标转换后,后面的图形绘制功能将跟随新坐标,转换前已经绘制的图形不会有任何的变化。另外,为了能恢复到坐标之前的状态,Canvas
定义了两个方法用于保存现场和恢复现场:
- public int save()
- 保存现场
- public void restore()
- 恢复现场到 save()执行之前额状态
下面的案例使用演示坐标的平移、旋转、缩放三种变换的使用。通过平移,我们沿 45度方向绘制出一系列额正方形;通过缩放,我们绘制一个万花筒;通过旋转,绘制一个手表四周的刻度
public class CoordinateView extends View {
public CoordinateView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Paint paint = new Paint() ;
paint.setAntiAlias(true) ;
paint.setColor(Color.RED) ;
paint.setStrokeWidth(2) ;
paint.setStyle(Style.STROKE) ;
//保存现场
canvas.save() ;
for (int i = 0; i <10; i++) {
canvas.drawRect(0, 0,150,50,paint) ;
canvas.translate(20, 20) ;
}
//恢复现场
canvas.restore() ;
//平移坐标,让接下来的图形绘制在上一次图像的下面
canvas.translate(0, 300) ;
canvas.save() ;
for (int i = 0; i < 10; i++) {
canvas.drawRect(0, 0,400,300,paint) ;
canvas.scale(0.9f, 0.9f,200,200) ;
}
canvas.restore() ;
//平移坐标,让接下来的图形绘制在上一次图像的下面
canvas.translate(0, 350) ;
canvas.save() ;
canvas.drawCircle(200, 200, 150, paint);
for(int i = 0; i < 12; i ++){
canvas.drawLine(310, 200, 350, 200, paint);
canvas.rotate(30, 200, 200);
}
canvas.restore();
}
}
如果不使用坐标的转换,我们需要通过找出图形的变化规律,不断计算各个点的坐标。以达到绘制一系列的目的。但是,有了坐标的转换,很轻松的将这一工作完成。运行如下:绘制第一种效果是,我们只需需啊哟考虑第一个正方形的位置和大小,即通过 canvas.drawRect(0, 0,150,50,paint) 语句绘制一个正方形,平移(20,20)坐标后,使用相同的挨骂绘制一个正方形。此时,新的正方形向右下角移动一段距离,按照相同的思路循环 10次,直到画完最后一个正方形为止;绘制第二种效果时,先调用 canvas.drawRect(0, 0,400,300,paint) 绘制一个大小为 400 * 300 的正方形。此时,我们注意到,正方形的中心是(200,150)。通过 canvas.scale(0.9f, 0.9f,200,200) 方法以(200,200)为中心,将当前新绘制的正方形缩小为撒花姑娘一个正方形的 0.9 倍,以同样的方式直到绘制结束为止;第三个绘制,我们先执行 canvas.drawCircle(200, 200, 150, paint);语句画一个圆,圆心坐标为(200,200),如果执行 canvas.drawLine(310, 200, 350, 200, paint);语句会绘制一条从 (310, 200)到 (350, 200) 的长为 40的直线。然后,以(200,200)为中心,每次旋转 30度,依次绘出 12 条直线。
我们没有计算新图形坐标,而是利用同一个绘图语句,通过改变画布坐标,轻松实现了复杂的绘图。需要注意的是一个绘图周期内最好 save 保存现场调用 restore恢复现场,这样才不会影响下一次绘图。
另外,Anroid中定义了一个名为 Matrix的类,该类定义了一个 3 *3 的矩阵,只要知道通过 Matrix同样可以实现坐标的变换,相关方法如下:
- 位移
- public void setTranslate(float dx, float dy)
- 旋转
- public void setRotate(float degrees, float px, float py)
- public void setRotate(float degrees)
- 缩放
- public void setScale(float sx, float sy)
- public void setScale(float sx, float sy, float px, float py)
- 拉斜
- public void setSkew(float kx, float ky)
- public void setSkew(float kx, float ky, float px, float py)
Matrix的应用范围很广,Canvas‘Shader 等都支持通过 Matrix 实现位移、旋转、缩放 等效果。Matrix额基本使用形如:
Matrix matrix = new Matrix();
matrix.setTranslate(10, 10);
canvas.setMatrix(matrix);
3.4、剪切区(Clip)
clip 是指剪切区,理解“剪切区”这个概念不需要费什么周折。我们想象一下,春乱花开的季节,你在海岸边的别墅里,面朝海边的墙上开了一个窗户。看潮起潮落、鸟来鸟往,伴随着落日余晖和万丈光霞,还不惬意。这里的“剪切区”就是在 Canvas上开一个口子,开了这个口子之后,接下来绘制的内容只有通过口子才能看见,口子外的图形就看不到了Canvas 提供了剪切区的功能,剪切区可以是一个 Rect 或者是一个 Path。两个剪切区还能进行图形运算,得到更加复杂的剪切区。相关方法如下:
- public boolean clipRect(Rect rect)
- public boolean clipRect(RectF rect)
- public boolean clipRect(float left, float top, float right, float bottom)
- public boolean clipRect(int left, int top, int right, int bottom)
- 以上 4个方法定义一个矩形的剪切区
- public boolean clipPath(Path path)
- 以上方法一个 Path剪切区,用于定义更加复杂的区域
现在有一张很照片,照片里有两只干净蠢萌的小狗。在 Canvas上开一个矩形的剪切区并将照片绘制在画布上,然后,我们看到什么 ?
public class ClipView extends View {
public ClipView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Bitmap bmpDog = BitmapFactory.decodeResource(getResources(), R.mipmap.dog);
//绘制完整照片
canvas.drawBitmap(bmpDog, 0, 0, null);
//平移坐标
canvas.translate(0, 500);
//定义剪切区
canvas.clipRect(new Rect(100, 100, 500, 500));
//再次绘制
canvas.drawBitmap(bmpDog, 0, 0, null);
}
}
从结果中看出,平移坐标后,在 Rect(100,100,500,500) 区域定义了一个剪切区,接下来的图片只有剪切区才会显示
剪切区还能进行图形运算,前面学习 Path是我们接触过 Op,事实上剪切区的 Op运算也没有什么太大的不同,一共有 6种:
public static enum Op {
DIFFERENCE,
INTERSECT,
REPLACE,
REVERSE_DIFFERENCE,
UNION,
XOR
}
Op.DIFFERENC : 计算 A 和 B 的差集范围,即 A-B,只有在此范围内的绘制内容才会被显示Op.REVERSE_DIFFERENCE : 计算 B 和 A的差集范围,即 B-A,只有在范围内的绘制内容才会被显示
Op.INTERSECT: 计算 B 和 A的交集范围,只有在范围内的绘制内容才会被显示
Op.REPLACE: 不论 A和 B 的集合状况,B 的范围将全部进行显示,如果和 A 有交集,则覆盖 A 的交集范围
Op.UNION : 计算 A 和 B的并集范围,两者所包括的范围的绘制内容欧惠被显示
Op.XO: 计算 A 和 B 的补集范围,也就是先获取 A 和 B 的并集,再减去 A 和 B 的交集。只有在范围内的绘制内容才会被显示
与剪切区 Op运算相关的方法如下:
- public boolean clipRect(RectF rect, Op op)
- public boolean clipRect(Rect rect, Op op)
- public boolean clipRect(float left, float top, float right, float bottom, Op op)
- public boolean clipPath(Path path, Op op)
根据上面的代码做修改,先创建一个矩形剪切区,再创建一个 Path剪切区(Path内添加一个圆),添加第二个剪切区时做 Op.UNION运算(计算 A 和 B的并集范围,两者所包括的范围的绘制内容欧惠被显示),运行结果显示剪切区是有一个矩形和圆构成的
//绘制完整图片
canvas.drawBitmap(bitmap, 0, 0,null) ;
//平移坐标
canvas.translate(0, 500);
//定义剪切区
canvas.clipRect(new Rect(100,100,500,500));
//定义一个新的剪切区,与上面一个 做 Op运算
Path path = new Path() ;
path.addCircle(500, 350, 200, Direction.CCW) ;
canvas.clipPath(path) ;
//再次绘制
canvas.drawBitmap(bitmap, 0, 0,null) ;
利用剪切区还可以实现帧动画的播放,制作游戏时,不断拍打翅膀的小鸟、轰然一声的爆炸效果等都可以通过剪切区很好的播放出来。我们以爆破效果为例,爆破的所有帧都是事先绘制在一张图片上,如下 :
上面一张大图包含了 7帧,定义一个 1/7 大小的剪切区,每隔一段时间按照顺序连续播放其中一帧,原理类似于以前的胶片电影,这样就构成了一个动感十足的动画
播放过程中,剪切区(clip)是固定不动的,实际上移动的恰恰是图片,图片每次向左移动一帧。假设图片总长度为 70.显示第一帧时,图片的 left为 0,然后向左移动一帧,left为 -10.向左移动 两帧,left 为 -20…向左移动 6帧,left 为 -60,此时,真整个动画播放完成。如果要循环播放,将 left的值重新置 0即可,具体实现看下面代码
public class ClipView extends View {
private int i = 0 ;
private Bitmap bitmap ;
public ClipView(Context context, AttributeSet attrs) {
super(context, attrs);
bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.k) ;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//获取位置的宽度和高
int width = bitmap.getWidth() ;
int height = bitmap.getHeight() ;
//剪切区
int frameWidth = width / 7;
Rect rect = new Rect(0,0,frameWidth,height) ;
canvas.save() ;
canvas.translate(100, 100) ;
canvas.clipRect(rect) ;
canvas.drawBitmap(bitmap, -i * frameWidth,0, null) ;
canvas.restore() ;
i++ ;
if(i == 7){
i = 0 ;
}
}
}
为了让动画每隔一段时间播放一帧,需要定义一个 Timer计时器,调用 postInvalidate()方法,发送消息让组件重绘
clipView = (ClipView) findViewById(R.id.clipview) ;
new Timer().schedule(new TimerTask() {
@Override
public void run() {
clipView.postInvalidate() ;
}
}, 2000,100);
运行效果如下:
3.5、案例:指针走动的手表
下面通过代码模拟一个时钟,我们知道时针总共有 3根针,分别为 时针、分针、秒针。表盘周围是一圈有规律长短分配的刻度。秒针每隔一秒移动一次,分针每隔一分钟移动一次,时针比较特殊,并不是每隔一小时移动一次,而是随着分针慢慢移动,为了不复杂下面设置成,每隔一小时,时针移动一次分步骤来解决这个问题,大致来讲分为 三步 :
- 绘制表盘周围刻度
刻度的变化规律是 1 长 4 短,通过旋转画布坐标来绘制刻度,一共 60根刻度,以 6 °度为单位对画布进行旋转,从 3 点钟所在的位置也就是 0°度开始绘制 - 绘制指针
指针有秒针‘分针和时针,我们抛弃三者联动的思路,采用实时获取设备的系统时间。读取出当前时间的时、分、秒数值。将数值转化成角度,再将角度转化成两个点的 坐标。
已知圆的中心点坐标(xo,yo),圆的半径 r,那么,圆上任何角度 α 对应的点的坐标公式为 :
x = xo + r * cosα
y = yo + r * sinα
要注意的是,cosα对应的 Math.cos()方法 和 Math.sin() 方法的参数是 弧度,所以要利用 Math.toRadians()方法将°度转成弧度 - 定义 Timer定时器
定义一个 Timer定时器,每隔一秒刷新一次绘图区,实现指针运动的效果
public class WatchView extends View {
private Paint paint ;
private Calendar calendar ;
public WatchView(Context context, AttributeSet attrs) {
super(context, attrs);
paint = new Paint() ;
paint.setAntiAlias(true) ;
paint.setColor(Color.GRAY) ;
paint.setStyle(Style.STROKE) ;
paint.setStrokeWidth(1) ;
calendar = Calendar.getInstance() ;
}
public void run(){
new Timer().schedule(new TimerTask() {
@Override
public void run() {
postInvalidate() ;
}
}, 0,1000) ;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//获取组件尺寸
int width = this.getMeasuredWidth() -getPaddingLeft() - getPaddingRight();
int height = this.getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
int len = Math.min(width, height) ;
//绘制表盘
drawPlate(canvas, len);
//绘制指针
drawPoints(canvas, len);
}
private void drawPlate(Canvas canvas, int len) {
canvas.save() ;
int r = (int) (len *0.5f) ;
int startX = r + getPaddingLeft() ;
int startY = r + getPaddingTop();
canvas.rotate(-90,startX,startY) ;
canvas.drawCircle(startX, startY, r, paint);
//化刻度(60个)
for (int i = 0; i < 60; i++) {
if(i%5 == 0){
paint.setColor(Color.RED) ;
paint.setStrokeWidth(4) ;
//取值 r 的 9/10
canvas.drawLine(startX + 9 * r / 10 , startY, len, startY, paint);
}else{
paint.setColor(Color.GRAY);
paint.setStrokeWidth(2);
//取值 r 的 1/15
canvas.drawLine(startX + 14 * r / 15 , startY, len , startY, paint);
}
//以圆中心将画笔旋转 6度
canvas.rotate((float)360/60,r+getPaddingLeft(), r+getPaddingTop());
}
canvas.restore() ;
}
private void drawPoints(Canvas canvas, int len) {
//获取系统的时间
calendar.setTimeInMillis(System.currentTimeMillis()) ;
//获取时分秒
int hours = calendar.get(Calendar.HOUR) % 12;
int miuntes = calendar.get(Calendar.MINUTE) ;
int seconds = calendar.get(calendar.SECOND) ;
//画是时针(顺时针)
int degree = 360 / 12 *hours ;
//转成 弧度值
double radians = Math.toRadians(degree) ;
//根据当前时间计算时针两个点的坐标
int r = (int) (len *0.5f) ;
int startX = r + getPaddingLeft() ;
int startY = r + getPaddingTop();
int endX = (int) (startX + r*0.5*Math.cos(radians)) ;
int endY = (int) (startY + r * 0.5 * Math.sin(radians));
canvas.save();
paint.setStrokeWidth(6);
//12即也是开始点,因为 Android的坐标系绘制相差 90°。所以需要将画面旋转 90度
canvas.rotate(-90,startX,startY) ;
canvas.drawLine(startX, startY, endX, endY, paint) ;
canvas.restore() ;
//画分针
degree = 360 / 60 * miuntes;
radians = Math.toRadians(degree);
endX = (int) (startX + r * 0.6 * Math.cos(radians));
endY = (int) (startY + r * 0.6 * Math.sin(radians));
canvas.save();
paint.setStrokeWidth(4);
//旋转同上
canvas.rotate(-90,startX,startY) ;
canvas.drawLine(startX, startY, endX, endY, paint);
canvas.restore();
//画秒针
degree = 360 /60 * seconds ;
radians = Math.toRadians(degree) ;
endX = (int) (startX + r * 0.8 * Math.cos(radians));
endY = (int) (startY + r * 0.8 * Math.sin(radians));
canvas.save();
paint.setStrokeWidth(2);
//旋转同上
canvas.rotate(-90,startX,startY) ;
canvas.drawLine(startX, startY, endX, endY, paint);
//形象的给秒针价格尾巴
radians = Math.toRadians(degree + 180) ;//因为是延伸出去的,所以多 180度。
endX = (int) (startX + r * 0.15 * Math.cos(radians));
endY = (int) (startY + r * 0.15 * Math.sin(radians));
canvas.drawLine(startX, startY, endX, endY, paint);
canvas.restore();
}
}
运行如下: