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

一、View的invalidate方法介绍

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

public void invalidate()
public void invalidate(int l, int t, int r, int b)
public void invalidate(Rect dirty)

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.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);
    }
}

注意:后面的案例统一使用该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>

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刷新一次
    }
}

下面是重点代码

// 小球的圆心坐标
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;
}

效果图:
在这里插入图片描述
如果想要垂直方向也变化,那也很简单,只需要修改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;
}

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

二、 坐标转换

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

public void translate(float dx, float dy)

2.1 平移

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

下面两段代码是等效的:

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

2.2 旋转

public void rotate(float degrees)

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

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

2.3 缩放

public void scale(float sx, float sy)

缩放画布的坐标,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)

2.4 拉斜

public void skew(float sx, float sy)

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

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

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

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();
}

效果图:
在这里插入图片描述
仔细阅读这三组图形的源代码,我们没有计算新图形的坐标,而是使用同一个绘图语句,通 过改变画布坐标,轻松实现了复杂的绘图。需要注意的是一个绘图周期内最好调用 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)

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

Matrix matrix = new Matrix();
matrix.setTranslate(10, 10);
canvas.setMatrix(matrix);

三、 剪切区(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个方法定义一个矩形的剪切区,除此之外path也可以用来裁剪区域。

public boolean clipPath(Path path)

以上方法定义一个 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);
}

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

3.2 剪切区的图形运算

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

public static enum Op {
    DIFFERENCE,//差集
    INTERSECT,//交集
    REPLACE, // 就比path多了这个而已
    REVERSE_DIFFERENCE, //反差集
    UNION,//并集
    XOR//补集
}

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各种形状

下面我们在上一个案例的基础上稍作修改,先创建一个矩形剪切区,再创建一个 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);

}

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

@Override
protected void onDraw(Canvas canvas) {
    // 背景设置为红色
    canvas.drawColor(Color.RED);
    // 设置裁剪区域
    canvas.clipRect(200, 200, 400, 400);
    // 在裁剪区域绘制绿色
    canvas.drawColor(Color.GREEN);
}

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

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 重新播放
}

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

四、综合案例

4.1 绘制指针走动的手表

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

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

要注意的是,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();


}

效果图:

在这里插入图片描述

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();
}

效果图如下:
在这里插入图片描述
如果想要超出的区域可以滑动查看,只需要在布局上套一个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>

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

五、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

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

5.1 PathMeasure常用方法

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

如下所示:

@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());

}

输出结果如下:

E/cys: measure1:400.0
E/cys: measure2:300.0

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

  1. isClosed函数
public boolean isClosed()

当关联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());

}

输出结果如下:

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

由于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());

}

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

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

该函数用于截取整个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);

}

效果图:
在这里插入图片描述
上图表示原图,下图表示截取后的图,并且截取的顺序是顺时针的,这个是由原图的绘制顺序决定的,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);

}

效果图:
在这里插入图片描述
由此可见,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);
}

稍作修改的效果图如下:
在这里插入图片描述
只需要修改下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);
}
  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值