当前位置:   article > 正文

Android绘图(二)使用 Graphics2D 实现动态效果

graphics2d

一、View的invalidate方法介绍

View 类定义了一组 invalidate()方法,该方法有好几个版本:

public void invalidate()
public void invalidate(int l, int t, int r, int b)
public void invalidate(Rect dirty)
  • 1
  • 2
  • 3

invalidate()用于重绘组件,不带参数表示重绘整个视图区域,带参数表示重绘指定的区域。如果要去追溯该方法的源码,大概就是将重绘请求一级级往上交到 ViewRoot,调用 ViewRoot的 scheduleTraversals()方法重新发起重绘请求,scheduleTraversals()方法会发送一个异步消息,调用 performTraversals()方法执行重绘,而 performTraversals()方法最终调用 onDraw()方法,简单来说,调用 View 的 invalidate()方法就相当于调用了 onDraw()方法,而 onDraw()方法中就是我们编写的绘图代码。

如果要刷新组件或者让画面动起来,我们只需调用 invalidate()方法即可。通过改变数据来影响绘制结果,这是实现组件刷新或实现动画的基本思路。
invalidate()方法只能在 UI 线程中调用,如果是在子线程中刷新组件,View 类还定义了另一组名为 postInvalidate 的方法:

public void postInvalidate()
public void postInvalidate(int left, int top, int right, int bottom)
  • 1
  • 2

1.1 案例-小球循环滚动

现在我们编写一个案例,让小球在 View 的 Canvas 中水平往返移动。当小球触碰到左边边界时往右移动,小球触碰到右边边界时往左移动,循环往复。

首先创建自定义View

public class MyView extends View {
    public MyView(Context context) {
        this(context,null);
    }

    public MyView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public MyView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

注意:后面的案例统一使用该View,只是init和onDraw方法不同而已,其他都是模板代码.
然后在Activity中使用

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:gravity="center"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.mchenys.nagivation.MyView
        android:id="@+id/myView"
        android:layout_width="match_parent"
        android:layout_height="300dp" />

</LinearLayout>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

Activity代码固定如下:


public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        MyView myView = findViewById(R.id.myView);
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                // 子线程刷新调用postInvalidate
                myView.postInvalidate();
            }
        }, 200, 50); // 延迟200ms执行,每50ms刷新一次
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

下面是重点代码

// 小球的圆心坐标
int centerX;
int centerY = 100; // y坐标固定
// 半径
int radius = 50;
// 小球颜色
int color = Color.RED;
// 移动的方向,true向右边,false向左边
boolean direction;
// 画笔
Paint mPaint = new Paint();

private void init() {
    mPaint.setAntiAlias(true);
    mPaint.setColor(color);
    centerX = radius;
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 绘制小球
    canvas.drawCircle(centerX, centerY, radius, mPaint);
    // 获取控件最大宽度
    int maxWidth = getMeasuredWidth();
    if (centerX < radius) {
        // 如果圆心x的位置小于半径,那么需要向右移动
        direction = true;
    } else if (centerX > maxWidth -  radius) {
        // 如果圆心x的位置大于控件最大宽度-圆的半径,那么需要向左边移动
        direction = false;
    }
    // 修改圆心x的坐标,每次变化10px
    centerX = direction ? centerX + 10 : centerX - 10;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

效果图:
在这里插入图片描述
如果想要垂直方向也变化,那也很简单,只需要修改centerY为随机值即可,如下所示:


// 小球的圆心坐标
int centerX;
int centerY; // y坐标固定
// 半径
int radius = 50;
// 小球颜色
int color = Color.RED;
// 水平移动的方向,true向右边,false向左边
boolean directionX;
// 垂直移动方向,true向下,false向上
boolean directionY;

// 画笔
Paint mPaint = new Paint();

private void init() {
    mPaint.setAntiAlias(true);
    mPaint.setColor(color);
    centerX = radius;
    centerY = radius;
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 绘制小球
    canvas.drawCircle(centerX, centerY, radius, mPaint);
    // 获取控件最大宽度和高度
    int maxWidth = getMeasuredWidth();
    int maxHeight = getMeasuredHeight();
    if (centerX < radius) {
        // 如果圆心x的位置小于半径,那么需要向右移动
        directionX = true;
    } else if (centerX > maxWidth - radius) {
        // 如果圆心x的位置大于控件最大宽度-圆的半径,那么需要向左边移动
        directionX = false;
    }
    // 修改圆心x的坐标,每次变化10px
    centerX = directionX ? centerX + 10 : centerX - 10;

    if (centerY < radius) {
        directionY = true;
    } else if (centerY > maxHeight - radius) {
        directionY = false;
    }

    // 修改圆心y的坐标,每次变化5px
    centerY = directionY ? centerY + 5 : centerY - 5;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

效果如下:
在这里插入图片描述

二、 坐标转换

默认情况下,画布坐标的原点就是绘图区的左上角,向左为负,向右为正,向上为负,向下为正,但是通过 Canvas 供的方法可以对坐标进行转换。转换的方式主要有 4 种:平移、旋转、缩放和拉斜

public void translate(float dx, float dy)
  • 1

2.1 平移

坐标平移,在当前原点的基础上水平移动dx个距离,垂直移动dy个距离,正负符号决定方向。坐标原点改变后,所有的坐标都是以新的原点为参照进行定位。
在这里插入图片描述

下面两段代码是等效的:

//代码段 1
canvas.drawPoint(10,10,paint);
//代码段 2
canvas.translate(10,10); // 先平移  
canvas.drawPoint(0,0,paint); // 在新的坐标原点下绘制
  • 1
  • 2
  • 3
  • 4
  • 5

2.2 旋转

public void rotate(float degrees)
  • 1

将画布的坐标以当前原点为中心旋转指定的角度,如果角度为正,则为顺时针旋转,否则为逆时针旋转。
在这里插入图片描述
旋转还能指定坐标作为旋转的中心

//以点(px, py)为中心对画布坐标进行旋转 degrees 度,为正表示顺时针,为负表示逆时针。
public final void rotate(float degrees, float px, float py)
  • 1
  • 2

2.3 缩放

public void scale(float sx, float sy)
  • 1

缩放画布的坐标,sx、sy 分别是 x 方向和 y 方向的缩放比例,小于 1 表示缩小,等于1 表示不变,大于 1 表示放大。画布缩放后,绘制在画布上的图形也会等比例缩放。缩放的单位是倍数,比如 sx 为 0.5 时,就是在 x 方向缩小 0.5 倍。
同样缩放也可以指定坐标作为缩放的中心点

// 以(px,py)为中心对画布进行缩放。
public final void scale(float sx, float sy, float px, float py)
  • 1
  • 2

2.4 拉斜

public void skew(float sx, float sy)
  • 1

将画布分别在 x 方向和 y 方向拉斜一定的角度,sx 为 x 方向倾斜角度的 tan 值,sy 为 y 方向倾斜角度的 tan 值,比如我们打算在 X 轴方向上倾斜 45 度,则 tan45=1,写成:canvas.skew(1,0)。

注意:坐标转换后,后面的图形绘制功能将跟随新坐标,转换前已经绘制的图形不会有任何的变化。另外,为了能恢复到坐标变化之前的状态,Canvas 定义了两个方法用于保存现场和恢复现场:

// 保存现场
public int save()
// 恢复现场到 save()执行之前的状态
public void restore()
  • 1
  • 2
  • 3
  • 4

2.5 案例-平移旋转缩放


// 画笔
Paint mPaint = new Paint();

private void init() {
    mPaint.setAntiAlias(true);
    mPaint.setStyle(Paint.Style.STROKE);
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 水平方向平移画布,然画布内容离控件的左上角有点间距
    canvas.translate(300, 100);

    mPaint.setColor(Color.RED);
    // 保存现场
    canvas.save();
    for (int i = 0; i < 10; i++) {
        // 绘制正方型
        canvas.drawRect(0, 0, 100, 100, mPaint);
        // 每绘制一个,则平移下画布原点
        canvas.translate(10, 10);
    }
    // 恢复现场
    canvas.restore();

    // 平移坐标,让接下来的图形绘制在上一次图形的下面
    canvas.translate(0, 220);
    mPaint.setColor(Color.WHITE);
    // 保存现场
    canvas.save();
    for (int i = 0; i < 10; i++) {
        canvas.drawRect(0, 0, 100, 100, mPaint);
        // 每绘制一个,则缩放一次,缩放中心是图形的中心点
        canvas.scale(0.9f, 0.9f, 50, 50);
    }
    // 恢复现场,恢复到上一次save的位置,也就是垂直平移了220的位置
    canvas.restore();


    //平移坐标,让接下来的图形绘制在上一次图形的下面
    canvas.translate(0, 150); // 垂直方向加上前面平移的220和此次150,那就是平移了370了
    mPaint.setColor(Color.RED);
    canvas.save();

    // 绘制时钟
    canvas.drawCircle(50, 50, 50, mPaint);
    for (int i = 0; i < 12; i++) {
        // 绘制直线线条(水平直线,y坐标在圆心上)
        canvas.drawLine(0, 50, 10, 50, mPaint);
        // 每次绘制完旋转30度,旋转中心是圆心,这样直线就变成刻度线了
        canvas.rotate(30, 50, 50);
    }
    canvas.restore();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56

效果图:
在这里插入图片描述
仔细阅读这三组图形的源代码,我们没有计算新图形的坐标,而是使用同一个绘图语句,通 过改变画布坐标,轻松实现了复杂的绘图。需要注意的是一个绘图周期内最好调用 save 保存现场调用 restore 恢复现场,这样才不会影响下一次绘图。

2.6 canvas中使用Matrix

Android 中定义了一个名为 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)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

Matrix 的应用范围很广,Canvas、Shader 等都支持通过 Matrix 实现移位、旋转、缩放等效 果。Matrix 的基本使用形如:

Matrix matrix = new Matrix();
matrix.setTranslate(10, 10);
canvas.setMatrix(matrix);
  • 1
  • 2
  • 3

三、 剪切区(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)
  • 1
  • 2
  • 3
  • 4

以上 4个方法定义一个矩形的剪切区,除此之外path也可以用来裁剪区域。

public boolean clipPath(Path path)
  • 1

以上方法定义一个 Path 剪切区,可以用于定义更加复杂的区域。

3.1 案例-裁剪相片

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    Bitmap sourceBmp = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher);
    // 绘制原图
    canvas.drawBitmap(sourceBmp, 0, 0, null);
    // 平移坐标,让新的绘制内容在原图下面
    canvas.translate(0, sourceBmp.getHeight());
    // 定义剪切区为原图的左上角(原图的1/4区域)
    canvas.clipRect(new Rect(0, 0, (int) (sourceBmp.getWidth()/2f), (int) (sourceBmp.getHeight()/2f)));
    //再次绘制图片
    canvas.drawBitmap(sourceBmp, 0, 0, null);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

效果图如下:
在这里插入图片描述

3.2 剪切区的图形运算

前面学习 Path 时我们接触过 Op,事实上剪切区的 Op 运算也没什么太大的不同,一共有 6 种:

public static enum Op {
    DIFFERENCE,//差集
    INTERSECT,//交集
    REPLACE, // 就比path多了这个而已
    REVERSE_DIFFERENCE, //反差集
    UNION,//并集
    XOR//补集
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

Op.DIFFERENCE:计算 A 和 B 的差集范围,即 A-B,只有在此范围内的绘制内容才会被显示。如下图所示:
在这里插入图片描述
Op.REVERSE_DIFFERENCE:计算 B 和 A 的差集范围,即 B-A,只有在此范围内的绘制内容才会被显示。如下图所示:
在这里插入图片描述
Op.INTERSECT:即 A 和 B 的交集范围,只有在此范围内的绘制内容才会被显示。如下图所示:
在这里插入图片描述
Op.REPLACE:不论 A 和 B 的集合状况,B 的范围将全部进行显示,如果和 A 有交集,则将覆盖 A 的交集范围。如下图所示:
在这里插入图片描述
Op.UNION:A 和 B 的并集范围,两者所包括的范围的绘制内容都会被显示。如下图所示:
在这里插入图片描述
Op.XOR: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可以add各种形状
  • 1
  • 2
  • 3
  • 4

下面我们在上一个案例的基础上稍作修改,先创建一个矩形剪切区,再创建一个 Path 剪切区(Path 内添加了一个圆),添加第二个剪切区时做 Op.UNION 运算(您也可以替换成其他运算),运行结果显示剪切区是由一个矩形和圆构成的。

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    Bitmap sourceBmp = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher);
    // 绘制原图
    canvas.drawBitmap(sourceBmp, 0, 0, null);
    // 平移坐标,让新的绘制内容在原图下面
    canvas.translate(0, sourceBmp.getHeight());
    // 定义剪切区为原图的左上角(原图的1/4区域)
    canvas.clipRect(new Rect(0, 0, (int) (sourceBmp.getWidth() / 2f), (int) (sourceBmp.getHeight() / 2f)));
    // 定义新的区域,这里使用path,也可以换成Rect,但是使用Path的化可以画出任何图形
    Path path = new Path();
    // 添加一个圆,中心点在距型中心
    path.addCircle(sourceBmp.getWidth() / 2f, sourceBmp.getHeight() / 2f, sourceBmp.getWidth() / 4f, Path.Direction.CCW);
    // 使用并集
    if (Build.VERSION.SDK_INT >= 28) {
        canvas.clipPath(path); // 遗憾的是高版本用不了,只能使用INTERSECT和DIFFERENCE
    } else {
        canvas.clipPath(path, Region.Op.UNION);
    }
    // 再次绘制图片
    canvas.drawBitmap(sourceBmp, 0, 0, null);

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

效果图:
在这里插入图片描述
再如我们在一个红色的区域截取一块区域来绘制绿色区域,可以这样做

@Override
protected void onDraw(Canvas canvas) {
    // 背景设置为红色
    canvas.drawColor(Color.RED);
    // 设置裁剪区域
    canvas.clipRect(200, 200, 400, 400);
    // 在裁剪区域绘制绿色
    canvas.drawColor(Color.GREEN);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

效果图:
在这里插入图片描述

3.3 通过裁剪实现帧动画效果

利用剪切区还可以实现帧动画的播放,制作游戏时,不断拍打翅膀的小鸟、轰然一声的爆 炸效果等都可以通过剪切区很好的播放出来。我们以爆炸效果为例,爆炸的所有帧都事先绘制在一整张图片上,如下:
在这里插入图片描述
上面一张大图包含了 7 帧,定义一个 1/7 大小的剪切区,每隔一段时间按照顺序连续播放其中的一帧,原理类似于以前的胶片电影,这样就构成了一个动感十足的动画。播放原理如下:
在这里插入图片描述
播放过程中,剪切区(clip)是固定不动的,实际上移动的恰恰是图片,图片每次向左移 动一帧。假设图片总长度为 70,显示第一帧时,图片的 left 为 0,然后向左移动一帧,left 为-10,向左移动两帧,left 为-20……向左移动 6 帧,left 为-60,此时,整个动画播放完毕。如果要循环播放,将 left 的值重新置 0 即可,具体实现请看下面的代码。

int index = 0; // 当前播放位置
Bitmap bmpBoom; // 图片

private void init() {
    bmpBoom = BitmapFactory.decodeResource(getResources(), R.drawable.image);
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    //获取位置的宽度和高度
    int width = bmpBoom.getWidth();
    int height = bmpBoom.getHeight();
    // 每一帧的长度
    int frameWidth = (int) (width / 7f);

    // 固定裁剪区域
    Rect rect = new Rect(0, 0, frameWidth, height);

    canvas.save(); // 好的习惯就是绘制不同的内容多使用save和restore方法,非必须
    canvas.translate((getMeasuredWidth() - frameWidth) / 2f, 0);//平移坐标,让画面居中显示,非必须
    canvas.clipRect(rect);//设置剪切区
    canvas.drawBitmap(bmpBoom, -index * frameWidth, 0, null);//关键代码,播放每一帧,注意是图片向做移动,所以是负数
    canvas.restore();
    index++; //index 加 1 以播放下一帧
    if (index == 7) index = 0;//播放完毕后将 index 重置为 0 重新播放
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

关于Activity的定时刷新的代码就不贴了,和前面的是一样的.效果图:
在这里插入图片描述

四、综合案例

4.1 绘制指针走动的手表

我们都知道,手表有 3 根指针:时针、分针和秒针,如下图所示:
在这里插入图片描述
表盘周围是一圈长短不一的刻度,秒针每隔一秒钟移动一次,分针每隔一分钟移动一次,时针比较特别,并不是每隔一小时移动一次,而是随着分针慢慢移动,为了使得代码不过于复杂,我们将时针设定为每隔一小时移动一格。我们分步骤来解决这个问题,总体来说,分成三步:

  1. 绘制表盘周围的刻度
    刻度的变化规律是 1 长 4 短,通过旋转画布坐标来绘制刻度,一共有 60 根刻度,以 6 度为单位对画布进行旋转,从 3 点钟所在位置也就是 0 度处开始绘制。
  2. 绘制指针
    指针有秒针、分针和时针,我们抛弃三者联动的思路,采用实时获取设备的系统时间,读取出当前时间的时、分、秒数值,将数值转换成角度,再将角度转换成两个点的坐标。已知圆的中心点坐标(x0、y0),圆的半径 r,那么圆上任何角度α对应的点的坐标公式为:
x = x0 + r*cosα
y = y0 + r*sinα
  • 1
  • 2

要注意的是,cosα 对应的 Math.cos()方法和 Math.sin()方法的参数是弧度,所以要利 用 Math.toRadians()方法将度转换成弧度。

  1. 定义 Timer 定时器
    定义一个 Timer 定时器,每隔一秒刷新一次绘图区,实现指针运动的效果。

下面直接上代码

private Paint paint;
private Calendar calendar;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.GRAY);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(1);
    calendar = Calendar.getInstance();
}
/**
* 在使用的地方调用,例如Activity中
*/
public void run() {
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            // 每隔一秒绘制一次
            postInvalidate();
        }
    }, 0, 1000);
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    //获取组件宽度
    int width = this.getMeasuredWidth();
    //获取组件高度
    int height = this.getMeasuredHeight();
    //计算圆盘直径,取短的
    int len = Math.min(width, height) - 20; // -20可以留出一些边距

    // 先平移画布,让它居中显示
    float offsetX = (width - len) / 2f;
    float offsetY = (height - len) / 2f;
    canvas.translate(offsetX, offsetY);

    //绘制表盘
    drawPlate(canvas, len);
    //绘制指针
    drawPoints(canvas, len);
}

// 绘制表盘
private void drawPlate(Canvas canvas, int len) {
    // 绘制前先保存
    canvas.save();
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(2);
    // 绘制圆
    float r = len / 2f;
    canvas.drawCircle(r, r, r, paint);
    // 绘制刻度(一共60个,每个占6度)
    for (int i = 0; i < 60; i++) {
        if (i % 5 == 0) {
            // 每隔4个短刻度绘制一个长刻度,设长刻度占半径的1/10
            paint.setStrokeWidth(4);
            paint.setColor(Color.RED);
            // 以3点钟方向开始绘制直线,起点(r + 9 * r / 10, r),终点(len,r),
            // 起点之所以是r + 9 * r / 10是因为参考的是3点钟的位置
            canvas.drawLine(r + 9 * r / 10, r, len, r, paint);
        } else {
            // 短刻度,长度占半径的1/15
            paint.setColor(Color.GRAY);
            paint.setStrokeWidth(1);
            //  以3点钟方向开始绘制直线,起点(r + 14 * r / 15, r),终点(len,r)
            canvas.drawLine(r + 14 * r / 15, r, len, r, paint);
        }
        //以(r,r)为中心,将画布旋转 6 度
        canvas.rotate(6, r, r);
    }
    // 绘制完成再恢复
    canvas.restore();
}

// 绘制指针
private void drawPoints(Canvas canvas, int len) {
    //先获取系统时间
    calendar.setTimeInMillis(System.currentTimeMillis());
    //获取时分秒
    int hours = calendar.get(Calendar.HOUR) % 12;//转换为 12 小时制
    int minutes = calendar.get(Calendar.MINUTE);
    int seconds = calendar.get(Calendar.SECOND);
    // 画时针
    // 先计算角度
    int degree = 360 * hours / 12;
    // 然后转成弧度
    double radians = Math.toRadians(degree);
    // 时针的起点是圆心坐标,终点需要通过计算
    int r = len / 2;
    int startX = r;
    int startY = r;
    // 这里需要*0.5f的话,时针就连接到圆上了,我们不需要这么长
    int endX = (int) (startX + r * Math.cos(radians) * 0.5f);
    int endY = (int) (startY + r * Math.sin(radians) * 0.5f);

    // 画一个中心实心点
    canvas.save();
    paint.setStyle(Paint.Style.FILL);
    canvas.drawCircle(startX, startY, 8f, paint);
    canvas.restore();

    //画时针
    paint.setStyle(Paint.Style.STROKE);
    // 绘制前先保存
    canvas.save();
    paint.setStrokeWidth(5);
    //0 度从 3 点处开始,时间从 12 点处开始,所以需要将画布旋转 -90 度
    canvas.rotate(-90, r, r);
    canvas.drawLine(startX, startY, endX, endY, paint);
    canvas.restore();

    // 画分针
    // 先计算角度
    degree = 360 * minutes / 60;
    radians = Math.toRadians(degree);
    // 这里需要*0.6f的话,分针就连接到圆上了,我们不需要这么长
    endX = (int) (startX + r * Math.cos(radians) * 0.6f);
    endY = (int) (startY + r * Math.sin(radians) * 0.6f);
    canvas.save();
    paint.setStrokeWidth(3);
    //0 度从 3 点处开始,时间从 12 点处开始,所以需要将画布旋转 -90 度
    canvas.rotate(-90, r, r);
    //画时针
    canvas.drawLine(startX, startY, endX, endY, paint);
    canvas.restore();

    //画秒针
    degree = 360 * seconds / 60;
    radians = Math.toRadians(degree);
    // 这里需要*0.8f的话,秒针就连接到圆上了,我们不需要这么长
    endX = (int) (startX + r * Math.cos(radians) * 0.8f);
    endY = (int) (startY + r * Math.sin(radians) * 0.8f);
    canvas.save();
    paint.setStrokeWidth(1);
    //0 度从 3 点处开始,时间从 12 点处开始,所以需要将画布旋转 -90 度
    canvas.rotate(-90, r, r);
    canvas.drawLine(startX, startY, endX, endY, paint);


    //再给秒针画个“尾巴”,也就是反向的线
    radians = Math.toRadians(degree - 180);
    endX = (int) (startX + r * Math.cos(radians) * 0.2f);
    endY = (int) (startY + r * Math.sin(radians) * 0.2f);
    canvas.drawLine(startX, startY, endX, endY, paint);
    canvas.restore();


}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150

效果图:

在这里插入图片描述

4.2 绘制直尺

在这里插入图片描述
分析图片可知每隔5cm绘制一个大刻度,每cm又等分为10mm
直接上代码


private Paint paint;
// 大字体的大小
private float bigCmSize = 30f;
// 小字体的大小
private float smallCmSize = 20f;
// 1mm所占的宽度, 单位px
private int unit = 5;
// 总长度 30cm
private int size = 300;
// 大刻度的长度
private int bigLineLength = 10 * unit;
// 中刻度长度
private int middleLineLength = 8 * unit;
// 小刻度长度
private int smallLineLength = 6 * unit;
// 正常刻度
private int normalLineLength = 4 * unit;
// 文本距离刻度的间距
private int textPadding = 2;
// 直尺的外边距
private int margin = 5 * unit;
// 支持的内边距
private int padding = 5 * unit;


private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.GRAY);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(1);
}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 计算直尺总宽度
    int width = margin * 2 + padding * 2 + size * unit;
    int widthMeasure = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
    super.onMeasure(widthMeasure, heightMeasureSpec);
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 先平移下,留点外边距
    canvas.translate(margin, margin);
    drawFrame(canvas);
    drawGraduate(canvas);
}

// 绘制刻度
private void drawGraduate(Canvas canvas) {
    canvas.save();
    // 先平移下,离直尺左边有点间隙
    canvas.translate(padding, 0);
    //paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(unit * 0.5f);
    paint.setColor(Color.GRAY);
    // 定义文本区域
    Rect textBound = new Rect();

    for (int i = 0; i <= size; i++) {
        // 刻度或者文字的起始x坐标
        int startX = i * unit;
        String text = String.valueOf(i / 10);
        // 居中对齐,这样就不需要计算偏移量了
        paint.setTextAlign(Paint.Align.CENTER);

        if (i % 50 == 0) {
            // 每隔5cm绘制一个大刻度
            canvas.drawLine(startX, 0, startX, bigLineLength, paint);
            // 绘制文本
            paint.setTextSize(bigCmSize);
            // 先获取文本的区域
            paint.getTextBounds(text, 0, text.length(), textBound);
            int textHeight = textBound.height();

            canvas.drawText(text, startX, bigLineLength + textHeight + textPadding, paint);
        } else if (i % 10 == 0) {
            // 每隔1cm绘制一个中刻度
            canvas.drawLine(startX, 0, startX, middleLineLength, paint);
            // 绘制文本
            paint.setTextSize(smallCmSize);
            // 先获取文本的区域
            paint.getTextBounds(text, 0, text.length(), textBound);
            int textHeight = textBound.height();
            canvas.drawText(text, startX, middleLineLength + textHeight + textPadding, paint);
        } else if (i % 5 == 0) {
            // 每隔0.5cm绘制一个小刻度
            canvas.drawLine(startX, 0, startX, smallLineLength, paint);
        } else {
            // 绘制正常刻度
            canvas.drawLine(startX, 0, startX, normalLineLength, paint);
        }
    }
    canvas.restore();
}

// 绘制边框
private void drawFrame(Canvas canvas) {
    canvas.save();
    paint.setColor(Color.WHITE);
    paint.setStyle(Paint.Style.FILL);
    // 宽度需要减去外边距
    Rect rect = new Rect(0, 0, getMeasuredWidth() - 2 * margin, 40 * unit);
    canvas.drawRect(rect, paint);
    canvas.restore();
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110

效果图如下:
在这里插入图片描述
如果想要超出的区域可以滑动查看,只需要在布局上套一个HorizontalScrollView即可,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    tools:context=".MainActivity">

    <HorizontalScrollView
        android:overScrollMode="never"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fillViewport="true">

        <com.mchenys.viewdemo.MyView
            android:id="@+id/myView"
            android:layout_width="match_parent"
            android:layout_height="300dp" />

    </HorizontalScrollView>
</LinearLayout>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

效果图如下:
在这里插入图片描述

五、PathMeasure实现路径动画

PathMeasure类似一个计算器,可以计算出指定路径的一些信息,比如路径的总长度,指定长度对于的坐标点等.
初始化方法如下:

// 初始化方法一:
PathMeasure pathMeasure = new PathMeasure();
Path path = new Path();
pathMeasure.setPath(path, true); // 参数2是forceClosed,true表示会计算终点到起点的距离

// 初始化方法二:
Path path = new Path();
PathMeasure pathMeasure = new PathMeasure(path, true); // 构造方法参数2是forceClosed
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

注意:forceClosed仅对PathMeasure测量的时候有作用,并不会影响到关联的Path本身.

5.1 PathMeasure常用方法

  1. getLength函数
// 用于获取计算路径的总长度,注意forceClosed=true计算的长度要大于forceClosed=false的
public float getLength() 
  • 1
  • 2

如下所示:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
   
    Path path = new Path();
    path.moveTo(0, 0);
    path.lineTo(0, 100);
    path.lineTo(100, 100);
    path.lineTo(100, 0);

    PathMeasure measure1 = new PathMeasure(path,true);
    PathMeasure measure2 = new PathMeasure(path,false);

    Log.e("cys", "measure1:" + measure1.getLength());
    Log.e("cys", "measure2:" + measure2.getLength());

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

输出结果如下:

E/cys: measure1:400.0
E/cys: measure2:300.0
  • 1
  • 2

可以看到上面绘制了3段path, forceClosed=true计算的长度是400,而forceClosed=false计算的长度是300

  1. isClosed函数
public boolean isClosed()
  • 1

当关联Path时PathMeasure的forceClosed=true或者Path调用了close,那么isClosed()返回true,否则返回false
例如:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    Path path1 = new Path();
    path1.lineTo(0,100);
    PathMeasure measure1 = new PathMeasure(path1,true); // forceClosed=true
    Log.e("cys", "measure1:" + measure1.isClosed());

    Path path2 = new Path();
    path2.lineTo(0,100);
    PathMeasure measure2 = new PathMeasure(path2,false);// forceClosed=false
    Log.e("cys", "measure2:" + measure2.isClosed());

    Path path3 = new Path();
    path3.lineTo(0,100);
    path3.close(); // path调用close
    PathMeasure measure3 = new PathMeasure(path3,false); // forceClosed=false
    Log.e("cys", "measure3:" + measure3.isClosed());

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

输出结果如下:

E/cys: measure1:true
E/cys: measure2:false
E/cys: measure3:true
  • 1
  • 2
  • 3
  1. nextContour函数
public boolean nextContour() 
  • 1

由于Path是由多条曲线构成的,该函数用于跳到下一个线段(path),返回值true表示跳转成功,否则失败,例如:

Paint paint = new Paint();
Path path = new Path();

private void init() {
    paint.setColor(Color.RED);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(5);
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(50,50);
    
    path.addRect(0, 0, 50, 50, Path.Direction.CW);
    canvas.drawPath(path, paint);
  
    path.addRect(0, 0, 100, 100, Path.Direction.CW);
    canvas.drawPath(path, paint);
  
    path.addRect(0 ,0, 150, 150, Path.Direction.CW);
    canvas.drawPath(path, paint);

    PathMeasure pathMeasure = new PathMeasure(path, false);
    do {
        float len = pathMeasure.getLength();
        Log.e("cys", "len=" + len);
    } while (pathMeasure.nextContour());

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

效果图:
在这里插入图片描述
画了3个Path图形,打印结果如下:

E/cys: len=200.0
E/cys: len=400.0
E/cys: len=600.0
  • 1
  • 2
  • 3
  1. getSegment函数
public boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)
  • 1

该函数用于截取整个Path的某个片段,通过startD和stopD来控制截取的长度,并将截取后的Path保存到参数dst中,最后一个参数startWithMoveTo表示dst的起始点是否要moveTo起点位置, true则移动,false则会将dst的起点连接到dst上一个path的末尾处
注意:
1)如果startD、stopD的数值不在取值范围[0,getLength]内,或者startD==stopD,则返回值为false,而且不会改变dst中的内容.
2)开启硬件加速器后,绘图会出现问题,因此在使用getSegment()函数时需要禁用硬件加速功能,通过setLayerType(LAYER_TYPE_SOFTWARE,null)来禁用.

使用示例:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(50, 50);
    path.addRect(0, 0, 50, 50, Path.Direction.CW);
    // 绘制原图
    canvas.drawPath(path, paint);


    canvas.translate(0, 100);
    Path dst = new Path();
    PathMeasure measure = new PathMeasure(path, false);
    // 截取原图的0~100长度的路径到dst中
    measure.getSegment(0, 100, dst, true);
    // 绘制dst
    canvas.drawPath(dst, paint);

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

效果图:
在这里插入图片描述
上图表示原图,下图表示截取后的图,并且截取的顺序是顺时针的,这个是由原图的绘制顺序决定的,Path.Direction.CW表示顺时针,Path.Direction.CCW表示逆时针, 如果改为逆时针的效果图如下:
在这里插入图片描述
当dst是不为空的Path时,startWithMoveTo就会起作用了.例如:
1)当startWithMoveTo=true

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(50, 50);
    path.addRect(0, 0, 50, 50, Path.Direction.CW);
    // 绘制原图
    canvas.drawPath(path, paint);


    canvas.translate(0, 100);
    // dst是非空的
    Path dst = new Path();
    dst.lineTo(0,150);
    
    PathMeasure measure = new PathMeasure(path, false);
    // 截取原图的0~100长度的路径到dst中
    measure.getSegment(0, 100, dst, true); 
    // 绘制dst
    canvas.drawPath(dst, paint);

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

效果图:
在这里插入图片描述
由此可见,startWithMoveTo=true且dst不为空时,新截取的区域的起点是会移动到dst的开始位置的

2)当startWithMoveTo=false
效果图:
在这里插入图片描述
由此可见,startWithMoveTo=false且dst不为空时,新截取的区域的起点是连接到dst的上一个线段的终点的

5.2 通过路径实现加载动画

效果图:
在这里插入图片描述
代码如下:


Paint mPaint = new Paint();
Path mCirclePath = new Path();
Path mDstPath = new Path();
PathMeasure mPathMeasure = new PathMeasure();
float mCurrAnimValue;

private void init() {
    mPaint.setAntiAlias(false);
    mPaint.setColor(Color.RED);
    mPaint.setStyle(Paint.Style.STROKE);
    mPaint.setStrokeCap(Paint.Cap.ROUND);
    mPaint.setStrokeJoin(Paint.Join.ROUND);
    mPaint.setStrokeWidth(5);
    setLayerType(LAYER_TYPE_SOFTWARE,null);

    // 绘制圆path
    mCirclePath.addCircle(100,100,50,Path.Direction.CW);
    mPathMeasure.setPath(mCirclePath, true);

    ValueAnimator animator = ValueAnimator.ofFloat(0, 1f);
    animator.setRepeatCount(ValueAnimator.INFINITE);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            mCurrAnimValue = (float) animation.getAnimatedValue();
            // 刷新onDraw
            invalidate();
        }
    });
    animator.setDuration(2000);
    animator.start();

}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // stop是从0~getLength变化的
    float stop = mPathMeasure.getLength() * mCurrAnimValue;
    mDstPath.reset();
    // 不断的获取片段到mDstPath中
    mPathMeasure.getSegment(0, stop, mDstPath, true);
    canvas.drawPath(mDstPath,mPaint);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45

稍作修改的效果图如下:
在这里插入图片描述
只需要修改下onDraw方法,代码如下:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // stop是从0~getLength变化的
    float length = mPathMeasure.getLength();
    float stop = length * mCurrAnimValue; // 0~length
    // 当进度小于0.5时,start=0,当进度大于0.5时,路径的起点逐渐靠近终点,当进度为1是,两个点重合
    float start = 0;
    if (mCurrAnimValue >= 0.5) {
        start = (2 * mCurrAnimValue - 1) * length; // 0~length
    }
    mDstPath.reset();
    // 此时start也是动态变化了.
    mPathMeasure.getSegment(start, stop, mDstPath, true);
    canvas.drawPath(mDstPath, mPaint);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/2023面试高手/article/detail/254608
推荐阅读
相关标签
  

闽ICP备14008679号