其实,手写签名,和画图有异曲同工之妙。
目录
一、绘制笔迹
二、清除笔迹
三、保存笔迹
四、完善清除功能
那我们直接点,以画图作为说明参考。
首先,我们需要什么?画布?然后,画笔?不,我们需要先新建一个继承于View类的子类
我们先把它取名为 SignView.java
同时,你发现这玩意报红,提示什么呢
它提示说:View 里面,没有一个可用的默认构造函数,行,那我们给它实现便是了
按流程走到这里
我兴高采烈的选择了第一个,因为看上去参数少点嘛,ok,代码如下
package com.kabun.myapplication;
import android.content.Context;
import android.view.View;
public class SignView extends View {
public SignView(Context context) {
super(context);
}
}
同时,将它丢进布局里面,
点击运行,哦豁,崩了,看下日志
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.kabun.myapplication/com.kabun.myapplication.MainActivity}: android.view.InflateException: Binary XML file line #8: Error inflating class com.kabun.myapplication.SignView
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2325)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2387)
at android.app.ActivityThread.access$800(ActivityThread.java:151)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1303)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:135)
at android.app.ActivityThread.main(ActivityThread.java:5254)
at java.lang.reflect.Method.invoke(Native Method)
at java.lang.reflect.Method.invoke(Method.java:372)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:905)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:700)
Caused by: android.view.InflateException: Binary XML file line #8: Error inflating class com.kabun.myapplication.SignView
at android.view.LayoutInflater.createView(LayoutInflater.java:616)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:743)
at android.view.LayoutInflater.rInflate(LayoutInflater.java:806)
at android.view.LayoutInflater.inflate(LayoutInflater.java:504)
at android.view.LayoutInflater.inflate(LayoutInflater.java:414)
at android.view.LayoutInflater.inflate(LayoutInflater.java:365)
at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:696)
at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:170)
at com.kabun.myapplication.MainActivity.onCreate(MainActivity.java:22)
at android.app.Activity.performCreate(Activity.java:5990)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1106)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2278)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2387)
at android.app.ActivityThread.access$800(ActivityThread.java:151)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1303)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:135)
at android.app.ActivityThread.main(ActivityThread.java:5254)
at java.lang.reflect.Method.invoke(Native Method)
at java.lang.reflect.Method.invoke(Method.java:372)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:905)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:700)
Caused by: java.lang.NoSuchMethodException: [class android.content.Context, interface android.util.AttributeSet]
at java.lang.Class.getConstructor(Class.java:531)
at java.lang.Class.getConstructor(Class.java:495)
at android.view.LayoutInflater.createView(LayoutInflater.java:580)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:743)
at android.view.LayoutInflater.rInflate(LayoutInflater.java:806)
at android.view.LayoutInflater.inflate(LayoutInflater.java:504)
at android.view.LayoutInflater.inflate(LayoutInflater.java:414)
at android.view.LayoutInflater.inflate(LayoutInflater.java:365)
at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:696)
at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:170)
at com.kabun.myapplication.MainActivity.onCreate(MainActivity.java:22)
at android.app.Activity.performCreate(Activity.java:5990)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1106)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2278)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2387)
at android.app.ActivityThread.access$800(ActivityThread.java:151)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1303)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:135)
at android.app.ActivityThread.main(ActivityThread.java:5254)
at java.lang.reflect.Method.invoke(Native Method)
at java.lang.reflect.Method.invoke(Method.java:372)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:905)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:700)
里面有两个导致的原因,我们直接看最后一个Caused by,说:
java.lang.NoSuchMethodException: [class android.content.Context, interface android.util.AttributeSet]
这个什么意思呢?可以通过源码跳转进去看下这个异常的定义
package java.lang;
/**
* Thrown when a particular method cannot be found.
*
* @author unascribed
* @since JDK1.0
*/
public
class NoSuchMethodException extends ReflectiveOperationException {
private static final long serialVersionUID = 5034388446362600923L;
/**
* Constructs a NoSuchMethodException
without a detail message.
*/
public NoSuchMethodException() {
super();
}
/**
* Constructs a NoSuchMethodException
with a detail message.
*
* @param s the detail message.
*/
public NoSuchMethodException(String s) {
super(s);
}
}
看类注释,说的是,当找不到一个特定的方法时,就会抛出来,额,好难...
所以是哪个方法???看样子,像是初始化时,找不到有两个参数的方法,莫非是一开始的这个,
试试
package com.kabun.myapplication;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
public class SignView extends View {
public SignView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
}
nice!跑起来了!
现在,开始入正题,如果我们要在屏幕上画画的话,那么,按道理来说,手指在屏幕上滑动的时候,紧接着,是不是该有一条紧随着您手指滑动的轨迹呢?那么,换句话说,我们是不是只要在你滑动的一连串位置上,画上一连串的笔迹点就好了?
那么,问题来了,你如何获取你手指在屏幕上滑动的具体位置呢?可以通过View类提供的这个方法 onTouchEvent ,这个方法提供了什么呢?你触摸屏幕时的坐标位置。我们只要重写这个方法即可,然后,系统就会在你触摸屏幕时不断地回调这个方法,我们就可以从方法返回的参数 MotionEvent 手势事件中,通过 MotionEvent 中的 getX() 或者 getY() 方法拿到对应的x坐标和y坐标,嘿嘿,是不是很简单?补一下原始代码
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
然后,我们定制一下,然后变成了下面酱紫
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
Log.e(TAG,"ACTION_DOWN getX = "+event.getX()+" getY = "+event.getY());
break;
case MotionEvent.ACTION_MOVE:
Log.e(TAG,"ACTION_MOVE getX = "+event.getX()+" getY = "+event.getY());
break;
case MotionEvent.ACTION_UP:
Log.e(TAG,"ACTION_UP getX = "+event.getX()+" getY = "+event.getY());
break;
}
return true;
}
我先解释下代码,在上面代码中,我将手势事件 event 划分了三种(为啥是三种?因为首先它定义的事件不止三种,我只是抽取其中我想用到的三种)进行相应的处理,分别是:
1、手指触碰到屏幕时 :就是当 event.getAction() 等于 MotionEvent.ACTION_DOWN 时
2、手指在屏幕上滑动时:就是当event.getAction() 等于 MotionEvent.ACTION_MOVE 时
3、手指离开屏幕时:就是当event.getAction() 等于 MotionEvent.ACTION_UP 时
至于打印日志中的那些 event.getX() 和 event.getY() ,因为手势事件里面有挺多信息的,而当前我们只需要里面的坐标信息就够了,也即是 getX() 和 getY() 分别对应 x 坐标和 y 坐标
顺便说下这个方法的返回值,为什么要 return true ?因为默认它可不是返回true的,而是
return super.onTouchEvent(event);
简单理解就是, super.onTouchEvent(event) 的值是 false ,不行你可以打印一下。它之前返回值是由父类super逻辑决定的,我现在直接把它写死,一直返回 true 。意味着这个 MotionEvent 手势事件 ,来到这里的时候,直接交由这个方法块里的代码进行处理,不再往下传递,return true 或者 return false 其实就是问你,是不是由你自己自行处理这个事件。如果你返回false 的话,你打印日志会发现,除了打印了 MotionEvent.ACTION_DOWN 这个事件,其他事件就没打印了,由于这里涉及事件分发的原理,所以,相关的知识不在此进行展开细说。
按需要,我们就返回 true ,顺便打印一下日志看看
01-07 16:16:34.967 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_DOWN getX = 394.81873 getY = 825.44446
01-07 16:16:35.024 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 392.34274 getY = 861.3992
01-07 16:16:35.041 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 399.88123 getY = 898.23474
01-07 16:16:35.057 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 402.525 getY = 926.572
01-07 16:16:35.074 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 402.525 getY = 945.5775
01-07 16:16:35.090 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 402.525 getY = 961.7938
01-07 16:16:35.106 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 405.995 getY = 975.4282
01-07 16:16:35.106 4690-4690/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_UP getX = 405.995 getY = 975.4282
看到没,x ,y 坐标都现世了!!
接下来要干嘛?按国标来走的话,应该是在上述一系列的xy点位置上,对应地绘制出一系列的点,那么,当这些点连起来的时候,轨迹就出来了。
那么,问题又来了,谁去使用这些坐标点呢?或者说,这些坐标点传给谁去处理呢?传给一个 叫 Path 的实例,没错,翻译过来意思就是路径,挺实在一娃,你想想,这么多个坐标点,最理想的情况当然是有那么一个东西或者工具,帮你把它们连起来显示在屏幕上啦,对不对?很好,Path 能帮你做到这些,它能很好地帮你把这些点连起来,但是,Path 从哪里来的呢?new 出来的
private Path mPath =new Path();
不过要注意,导入正确的包,导入的是 android.graphics.Path
好了,怎么使用它呢?俗话说,两点连成一条线,也就是说,应该是要有序地从某个a点连到某个b点,Path 里面有个方法叫 lineTo
/**
* Add a line from the last point to the specified point (x,y).
* If no moveTo() call has been made for this contour, the first point is
* automatically set to (0,0).
*
* @param x The x-coordinate of the end of a line
* @param y The y-coordinate of the end of a line
*/
public void lineTo(float x, float y) {
isSimplePath = false;
nLineTo(mNativePath, x, y);
}
这个方法注释大概意思就是:从上次的点,到这次指定的点xy之间,添加一条线。但注意的是,如果在此之前,moveTo 这个方法没被调用过的话,那么第一个点会被自动设认为(0,0)也就是左上角
换句话说,首先,你也看到这个方法需要传两个参数 x 和 y,这两个参数可以定位到一个点,这个 lineTo 方法可以拉一条线接到这个点(x,y)上,但是问题呢,就是从哪里也就是从哪个起点连到当前的这个指定的点(x,y)上呢?那么,按道理来说,在使用 lineTo 之前,应该是有相应的那么一个方法是去设置起点的,如果不设置的话,就会将第一个点设为(0,0),行,那就(0,0)吧
那就试试嘛~
我们直接将手指在屏幕上滑动时:也就是上述提到的当event.getAction() 等于 MotionEvent.ACTION_MOVE 时,处理一下获得的坐标点,就是把这个点(x,y)传给 path 实例的 lineTo 方法,如下
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.e(TAG, "ACTION_DOWN getX = " + event.getX() + " getY = " + event.getY());
break;
case MotionEvent.ACTION_MOVE:
mPath.lineTo(event.getX(),event.getY());
Log.e(TAG, "ACTION_MOVE getX = " + event.getX() + " getY = " + event.getY());
break;
case MotionEvent.ACTION_UP:
Log.e(TAG, "ACTION_UP getX = " + event.getX() + " getY = " + event.getY());
break;
}
return true;
}
这样就可以了吗?跑跑试试?
怎么样?啥也没有对不对?先听我狡辩,path通过 lineTo 方法收集完你的滑动信息后,这个path,理应交给某个类进行处理的,但是,你看到这个路径path有交给谁了么?并没有。所以,我们应该想一下,这个路径path应该给谁?谁可以处理这一大条路径。答案是画布 Canvas 为什么是Canvas 呢?因为你想画东西的话,肯定要载体吧?如果你的手指是画笔,那么,屏幕应该算是画板了吧?那你可以把Canvas 当成画板,毕竟,人家本来就叫画布,叫它做画板,也不算委屈它。那么, 画笔的大小直径粗细呢?画笔的颜色呢?莫急,一步步来
先说Canvas,用到哪学到哪,它里面提供了一个专门跟path相关的方法叫 drawPath
/**
* Draw the specified path using the specified paint. The path will be filled or framed based on
* the Style in the paint.
*
* @param path The path to be drawn
* @param paint The paint used to draw the path
*/
public void drawPath(@NonNull Path path, @NonNull Paint paint) {
super.drawPath(path, paint);
}
方法注释的直译意思是:使用指定的绘制工具绘制指定的路径。路径将根据颜料中的样式被填充或加框。
(我其实挺郁闷的,为啥是画布可以draw这个path出来...)
什么意思呢?注释中的 the specified path 指定的路径 以及 the specified paint 指定的颜料 恰恰对应 drawPath 的两个参数 (@NonNull Path path 和 @NonNull Paint paint) ,那么,这么说的话,画笔的颜色以及样式就是由 paint 决定的。所以,我们要把paint颜料准备一下,也是new出来
private Paint mPaint=new Paint();
那么,paint 也到手了,就差画布了,那么画布 Canvas 去哪里搞回来呢?是 onDraw 方法!为啥是它呢?先看源码与注释
/**
* Implement this to do your drawing.
*
* @param canvas the canvas on which the background will be drawn
*/
protected void onDraw(Canvas canvas) {
}
方法注释的直译意思是:实现这个来完成你的画画(或者说绘制更合适些)
参数的直译意思是:将在其上绘制背景的画布
大概就是说,你可以通过实现这个方法,来完成你的绘制,然后,这个参数canvas就是你将要在它上面绘制东西的画布,所以这个参数canvas也即是画板。那么,其实通过名字大概也可以猜到,ondraw 就是当进行绘制的时候会被调用
我们先实现一下它,它是在View里面的,所以,你只要打个 ond..就会有代码提示了
此时,选择第一个就是了,默认实现代码如下
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
好了,龙珠已到位,可以召唤神龙了。目前,我们已经分别准备好了:
1、存放了手势事件的笔迹的path
2、设置颜料的paint
3、两者的载体画板canvas
行, 直接上代码,顺便打印一下日志
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.e(TAG, "onDraw canvas = " + canvas);
canvas.drawPath(mPath,mPaint);
}
跑一下,看一下日志
01-09 16:49:27.631 3237-3237/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.GLES20RecordingCanvas@9be9b22
很棒,你也看到了,onDraw 在app一启动的时候,自动回调了,并且canvas不为空
然后,我们试着划几下
你会发现,没啥东西在屏幕上出来,再看下日志
2021-01-09 17:44:56.969 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@79fbf4b
2021-01-09 17:44:57.924 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_DOWN getX = 308.025 getY = 367.46667
2021-01-09 17:44:58.000 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.1113 getY = 373.64154
2021-01-09 17:44:58.016 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 321.01874 getY = 387.95197
2021-01-09 17:44:58.034 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 323.1 getY = 410.25366
2021-01-09 17:44:58.051 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 325.29373 getY = 446.30988
2021-01-09 17:44:58.067 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 323.1 getY = 473.53394
2021-01-09 17:44:58.084 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 321.01874 getY = 489.6927
2021-01-09 17:44:58.100 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 318.82498 getY = 504.10065
2021-01-09 17:44:58.116 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 515.1077
2021-01-09 17:44:58.133 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 526.23517
2021-01-09 17:44:58.152 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 534.9575
2021-01-09 17:44:58.168 2259-2259/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 539.5328
onDraw 只是在app启动的时候被调用了一次,后面就没动静了....就挺尴尬的...
所以,我们该怎样才能让 onDraw 这货动起来? 来 ,上才艺!它就是 invalidate() ,它也是在View里面的,直接看它的源码与注释
/**
* Invalidate the whole view. If the view is visible,
* {@link #onDraw(android.graphics.Canvas)} will be called at some point in
* the future.
*
* This must be called from a UI thread. To call from a non-UI thread, call
* {@link #postInvalidate()}.
*/
public void invalidate() {
invalidate(true);
}
直译过来的意思就是 :
使整个视图无效。如果视图是可见的,onDraw将在将来的某个时间点被调用。这必须从UI线程调用。要从非ui线程调用,调用postInvalidate ()
这翻译大概意思呢,就是只要我们调用了这个方法,那么 onDraw() 将会被调用,是不是就意味着画布就绘制出我们期望放置的内容了?行,试试
我选择在 onTouchEvent 返回值之前,每次处理完手势事件坐标点之后,进行画布的绘制方法调用,运行效果
再看下日志
2021-01-09 21:58:53.450 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@79fbf4b
2021-01-09 21:58:55.034 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_DOWN getX = 329.625 getY = 291.55554
2021-01-09 21:58:55.067 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 327.4875 getY = 291.55554
2021-01-09 21:58:55.067 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.085 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 323.11148 getY = 293.67773
2021-01-09 21:58:55.085 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.100 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 318.82498 getY = 300.80896
2021-01-09 21:58:55.100 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.117 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 316.57498 getY = 311.70837
2021-01-09 21:58:55.117 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.134 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 320.8141
2021-01-09 21:58:55.134 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.149 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 333.96008
2021-01-09 21:58:55.149 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.166 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 347.46216
2021-01-09 21:58:55.166 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
2021-01-09 21:58:55.183 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 314.49374 getY = 355.5075
2021-01-09 21:58:55.183 2672-2672/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@3b343d4
可以证明,onDraw() 在每次滑动之后,都被成功地调用了,同时,画布终于有痕迹了
不过,话说回来,这画迹有点诡异,但是依稀看出画出的这一坨黑色里面有那么一丝丝规律的,就是我手势去到哪,这坨黑色就在哪蹦跶。这又抛出了一个新问题了,为什么会呈现这般模样呢?既然是样子的问题,那么就追究到底,样子问题归谁管?样子问题说白了就是样式问题,谁管的样式?还记得这个绘制它出来的 drawPath 方法吗?
请看红框标示的内容,以及之前的注释描述 路径将根据颜料中的样式被填充或加框
如此看来,这次的样式问题,颜料paint全责。 注意注释里的这句: the Style in the paint 说不定,在paint 类里面,可以找找跟style相关的方法
你看,这是不是style
我们看看这个style到底是怎么回事
/**
* The Style specifies if the primitive being drawn is filled, stroked, or
* both (in the same color). The default is FILL.
*/
public enum Style {
/**
* Geometry and text drawn with this style will be filled, ignoring all
* stroke-related settings in the paint.
*/
FILL (0),
/**
* Geometry and text drawn with this style will be stroked, respecting
* the stroke-related fields on the paint.
*/
STROKE (1),
/**
* Geometry and text drawn with this style will be both filled and
* stroked at the same time, respecting the stroke-related fields on
* the paint. This mode can give unexpected results if the geometry
* is oriented counter-clockwise. This restriction does not apply to
* either FILL or STROKE.
*/
FILL_AND_STROKE (2);
Style(int nativeInt) {
this.nativeInt = nativeInt;
}
final int nativeInt;
}
直译过来的意思是:样式指定所绘制的原语是填充的、描边的,还是两者都是(用相同的颜色)。默认是填充
大概意思就是,指定的样式有这三种,只是默认用的是填充
如你所见,这个玩意是个枚举,里面有三个枚举常量,分别是:
1、FILL :填充,使用此样式绘制的几何图形和文本将被填充,忽略所有与笔画相关的设置
2、STROKE:(用笔等)画,使用这种样式绘制的几何图形和文本将被描边,这与绘图上与描边相关的字段有关
3、FILL_AND_STROKE:使用这种样式绘制的几何图形和文本将同时被填充和描边,这与绘图上与描边相关的字段有关。如果几何图形是逆时针方向,这种模式会产生意想不到的结果。此限制不适用于填充或笔画
如果按照这注释的意思的话,估计只有 stroke 这个跟描边相关的样式才符合我们的需求,同时,注意下,这里 style 枚举的注释说道,默认是填充的,所以,我们可以验证下,试试看下paint默认情况下的style是个啥,补充一下代码,在构造函数里:
public SignView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
Log.e(TAG, " mPaint.getStyle() = " + mPaint.getStyle());
}
打印一下日志发现:
2021-01-10 10:51:43.111 2185-2185/com.kabun.myapplication E/com.kabun.myapplication.SignView: mPaint.getStyle() = FILL
看到没,paint的默认样式果然是 Fill 填充,我现在不能完全肯定是这个样式导致的问题,但是,可以测试一下,在构造方法里设置一下颜料的样式:
public SignView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint.setStyle(Paint.Style.STROKE);
Log.e(TAG, " mPaint.getStyle() = " + mPaint.getStyle());
}
跑一下app:
你看!!笔迹是不是出来了?!
再看下日志
2021-01-10 10:56:41.869 2456-2456/com.kabun.myapplication E/com.kabun.myapplication.SignView: mPaint.getStyle() = STROKE
颜料的样式已经成功改为描边了。不过是否留意到,我开始画的地方和笔迹的起始地方对不上?
标示 1 的地方是我触摸的位置,也是期望笔迹的开始的位置,但真正的笔迹开始位置是 标示 2 的地方.......(这现象告诉我一个道理,期望与现实之间的落差,只会迟到,不会不到。)为什么呢?路径的问题可以回头找下路径解决,是否还记得我们刚对这个lineTo方法的分析:
现在试出结果了,在我们没有手动设置好起点的话,第一个点确实被初始化为 (0,0)了,然后,我们手指落下的点,自然地接到了起点 (0,0)也就是屏幕左上方的坐标点的连线痕迹,所以,破案。那么, 按照注释所说,我们只要在生成第一个点的时候,调用 moveTo 方法去设置好起点即可。那么,怎样才算是第一个点呢?换句话问就是,moveTo 这个方法要放在哪里去调用才合适?按道理说,路径的起点应该是笔迹的起点,也就是手指每次落下的那个位置,也就是对应着手势事件中的 MotionEvent 的ACTION_DOWN 类型。理论是这样的,我们实践一下:
补了一发代码,使得每次接触事件产生的时候,设置对应的笔迹起点,跑一下
看,是不是就挺像那么一回事了~
不过,你也发现了..这字...好像.....多了点东西
尴尬,我想清空内容重新写,怎么清空呢?将app重启?不,使不得,优雅点,换台手机吧,开个玩笑。既然内容是由画布联合路径,加上颜料的点缀,通过画布自身的方法绘制出来的,那么,解铃还须系铃人,这三位之中,应该找谁买单?
画布吗?可以看下画布自身有没有跟还原或者清空之类的相关方法,或者直接点,绘制一片跟屏幕原始颜色一样的白色,不也一样效果么,屏幕变回一片白色不就好了~但是,我要说但是了,画布是在ondraw方法里传过来的, 也就是说,每次都要刷新完绘制之后,才能拿到画布canvas这货,即使,你拿到它的引用,赋予给一个画布变量,通过画布清空了内容,但是,刷新时,该干嘛还是干嘛,如果说通过设置标志位进行判断的话,会不会有点麻烦呢哎下一个
那路径呢?目前来看,路径主要是收集坐标点信息,然后将整个自身丢给画布了,我很好奇,如果我可以删掉它里面的那一堆坐标点信息,或者说,将path还原了之后,再丢给画布,画布能不能继续蹦跶?
至于颜料,目前我只是设置了颜料的样式,然后就将颜料递给了画布大哥了,不妥,这货怼不得,无从下手
我太难了....
行吧,先尝试从路径下手,目前,我们只是使用了路径的两个方法,分别是moveTo 设置笔迹起点以及 lineTo 连接坐标点,按道理,它应该有个类似存储坐标点的地方吧,然后,在我看来,path 就是个存储坐标点的货,不然那些坐标点它拿来干嘛~,按此强盗思路,我顺腾摸瓜找到了类似的方法,什么重置判断是否为空之类的方法:
/**
* Clear any lines and curves from the path, making it empty.
* This does NOT change the fill-type setting.
*/
public void reset()
这个方法看着就像还原:清除路径上的任何线条和曲线,使其为空。这不会改变填充类型设置。
还有这个:
/**
* Returns true if the path is empty (contains no lines or curves)
*
* @return true if the path is empty (contains no lines or curves)
*/
public boolean isEmpty()
翻译:如果路径为空(不包含直线或曲线)则返回true
我们可以在尝试清空路径上的线之前和之后,判断下路径,同时记住要刷新绘制,添加清空逻辑:
/**
* 清空
*/
public void clear() {
Log.e(TAG, "clear()");
if(mPath!=null){
Log.e(TAG, "before clear mPath isEmpty => "+mPath.isEmpty());
mPath.reset();
Log.e(TAG, "after clear mPath isEmpty => "+mPath.isEmpty());
invalidate();
}
}
看,有效的,这法子行得通,再看下日志
1-01-11 10:12:55.423 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@58e7363
2021-01-11 10:12:55.439 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 779.8769 getY = 765.6889
2021-01-11 10:12:55.440 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@58e7363
2021-01-11 10:12:55.458 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_MOVE getX = 799.4249 getY = 765.6889
2021-01-11 10:12:55.459 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@58e7363
2021-01-11 10:12:55.497 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: ACTION_UP getX = 799.4249 getY = 765.6889
2021-01-11 10:12:55.506 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@58e7363
2021-01-11 10:12:56.229 2502-2502/com.kabun.myapplication E/demo:MainActivity$1.onClick(L:29): mSignView com.kabun.myapplication.SignView{758f819 V.ED..... ........ 0,0-1080,1680 #7f080122 app:id/signView}
2021-01-11 10:12:56.229 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: clear()
2021-01-11 10:12:56.229 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: before clear mPath isEmpty => false
2021-01-11 10:12:56.229 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: after clear mPath isEmpty => true
2021-01-11 10:12:56.239 2502-2502/com.kabun.myapplication E/com.kabun.myapplication.SignView: onDraw canvas = android.view.DisplayListCanvas@58e7363
clear被调用之前,path是有东西的,清空之后,path 就真的变空了
来到这里了,画图你会了,清空画图你也会了,要不,保存一下这个画图?这么好看不存下浪费了是不是...那么问题来了,怎么保存呢?怎么去获取画布canvas上的内容呢?如何将 canvas 通过一堆操作之后,输出为一个图像文件呢?canvas的确提供了一些操作,其实如果在你想凭空 new 一个画布出来的时候,canvas 就有这么一个构造方法:
/**
* Construct a canvas with the specified bitmap to draw into. The bitmap
* must be mutable.
*
* The initial target density of the canvas is the same as the given
* bitmap's density.
*
* @param bitmap Specifies a mutable bitmap for the canvas to draw into.
*/
public Canvas(@NonNull Bitmap bitmap)
翻译:使用指定的位图构造画布。位图必须是可变的。画布的初始目标密度与给定的位图密度相同
其实里面的意思,大概是你往这个canvas构造方法里面传一个参数bitmap进去,那么,你之后的canvas的绘制内容都会绘制到bitmap上去,就相当于用位图代替原先的画布了。这样一来,无意外的话,我们再从理论上推测的话,只要将bitmap输出到文件,不是就相当于保存图片了吗~是的,理想很丰满,但你bitmap从哪里来?新建一个?怎么新建?新建一个咋样的?首先我们得先构建一个bitmap出来,可以通过bitmap自身的方法
/**
* Returns a mutable bitmap with the specified width and height. Its
* initial density is as per {@link #getDensity}. The newly created
* bitmap is in the {@link ColorSpace.Named#SRGB sRGB} color space.
*
* @param width The width of the bitmap
* @param height The height of the bitmap
* @param config The bitmap config to create.
* @throws IllegalArgumentException if the width or height are <= 0, or if
* Config is Config.HARDWARE, because hardware bitmaps are always immutable
*/
public static Bitmap createBitmap(int width, int height, @NonNull Config config) {
return createBitmap(width, height, config, true);
}
这个方法是怎么一个情况呢,你会看到这个方法的需要传入三个参数,前面两个就是宽度和高度,就是你要的bitmap尺寸,第三个是config配置,这个配置是什么?直接看它的源码:
/**
* Possible bitmap configurations. A bitmap configuration describes
* how pixels are stored. This affects the quality (color depth) as
* well as the ability to display transparent/translucent colors.
*/
public enum Config
翻译:可能的位图的配置。位图配置描述像素的存储方式。这影响质量(颜色深度)以及显示透明/半透明颜色的能力。
所以,这个东西决定了bitmap的显示质量,注意哦,这也是个枚举哦,它需要让你自己选择要哪个配置哦,然后,你会看到一堆 ALPHA_8,RGB_565,ARGB_8888 ... 诸如此类的常量可能会懵,没关系,先统一说明,再拆开来看。
一般来说,一幅完整的图像,是由三种基本色分别是红色red, 绿色green, 蓝色blue构成三个颜色的首字母就演变成了常见的 RGB 了,后面随着科技的发展,多了一个叫透明度Alpha的东西也就是大写 A,后面人称 ARGB 四件套。
然后是后面的数字什么8888之之类的,一般来说,按照计算机的标准,一种基本颜色的深度用一个字节也就是8位即为 2的8次方来标示,大概是256个层级。假设一个红色的深浅度是用8位就是0-255来表示,那么255就是根正苗红的红,0的话就不红了,数字越大越深。现在的 8888 就是4个8那么多,其实就是对应着4件套 ,每一个8位数值分别对应一个颜色值的程度,4个8位加起来一共就是32位,人称32位图。
然后就是 RGBA_F16 ,这个又是什么鬼..其实,F 是 float的意思,就浮点数,16就是16位的意思,合起来就是16位的浮点数,人称半精度浮点数,因为float占用4字节的,现在它只用了2字节,也就是16位,所以才叫半精度。
最后一个是 HARDWARE ,这个和RGBA_F16 都是比较新的接口,在 api26 也就是 Android 8.0 才开始支持的,这东西其实就是只将图片的数据存在显存里,不需要占用应用的堆内存,那么,一般来说应该是应用的堆内存一份数据,显存一份数据,一人一份的,那么,现在就可以是对应地减少了内存的消耗。
所以,我选择了钛合金8888的配置,不上不下不高不低。ok,这个config选好了,但是,宽和高还没处理,这个值怎么定义好呢?按套路走的话,bitmap的尺寸是不是应该和整个画布的尺寸保持一致,也就是说,你画布多大我位图就多大,对吧?所以,我们将画布的宽高传给位图就好了,但是,我们又如何得知当前画布的尺寸大小呢?退后,我要开始装x了,想一下,画布的大小是不是就是故事一开头,我们定义的这个 SignView 的view 的大小?是的,所以,我们应该怎么获取view 的宽高?view 提供了对应的方法,我就不扯淡了直接上代码:
//位图的绘制内容输出者:真正负责绘制签名笔迹的画布
private Canvas mCanvas;
//用来存放签名笔迹绘制内容的位图
private Bitmap mCacheBitmap;
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCacheBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mCacheBitmap);
}
如上所写,mCanvas用来控制绘制内容的,mCacheBitmap 是用来放置绘制内容的,getWidth() 和 getHeight() 都是View自带的方法,分别获取当前view自身的宽与高。有人可能会问,为什么要在 onSizeChanged 的里面是去做初始化呢,这就涉及view 的绘制流程的知识了,后面我会另外写一篇来吹吹关于绘制流程的水。说回onSizeChanged ,主要是这个方法被调用时,view 已经测量过并确定好自身的大小,所以,我们可以在这个时候拿它的宽高。注意哦,不是必须在这个时候拿,而是可以在这个时候拿。
ok,既然 bitmap 已经搞出来,那么mCanvas怎么实现绘制呢?毕竟,现在mCanvas绘制的东西会自动填充到mCacheBitmap里面去了嘛,我只要让mCanvas实现绘制就好了。在之前呢,我们是将path传给在ondraw调用时返回的画布使用,不过,那时是因为我们要使用的画布,也就是跟当时SignView相关联的画布,只能在ondraw被调用时,拿到。但现在我们不需要在当前的view绘制并显示了,所以,我们只要在path路径拿到手势事件的信息之后,就可以将路径传给mCanvas处理绘制了。意思就是看如下的代码:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mPath.moveTo(event.getX(), event.getY());
Log.e(TAG, "ACTION_DOWN getX = " + event.getX() + " getY = " + event.getY());
break;
case MotionEvent.ACTION_MOVE:
mPath.lineTo(event.getX(), event.getY());
Log.e(TAG, "ACTION_MOVE getX = " + event.getX() + " getY = " + event.getY());
break;
case MotionEvent.ACTION_UP:
Log.e(TAG, "ACTION_UP getX = " + event.getX() + " getY = " + event.getY());
break;
}
//真正负责绘制签名笔迹的画布,在这里接收路径mPath,以及事先定义好的颜料
mCanvas.drawPath(mPath, mPaint);
invalidate();
return true;
}
如上所写,我在每次处理完对应的手势事件之后,也就是mPath拿到对应的坐标点后,进行统一的mCanvas处理绘制,再去调用 invalidate() 时,就是轮到当前界面的画布绘制了。如此一来,保存笔迹的画布与当前signview的画布几乎是同时完成绘制内容的了,只是两者的显示内容的地方不一样罢了。那么,现在理论上来说 mCacheBitmap 上应该是有内容的了,所以,我们只要将bitmap保存到本地就好了。在界面上添加一个保存按钮,同时添加对应的保存逻辑。
现在又有新问题了?bitmap 如何输出到一个文件? 这个文件要求是个图像文件,但是图像好像有几个格式喔,例如什么 JPG,PNG 之类的,不慌,我这边向您推荐 bitmap 自家出品的 compress 方法
/**
* Write a compressed version of the bitmap to the specified outputstream.
* If this returns true, the bitmap can be reconstructed by passing a
* corresponding inputstream to BitmapFactory.decodeStream(). Note: not
* all Formats support all bitmap configs directly, so it is possible that
* the returned bitmap from BitmapFactory could be in a different bitdepth,
* and/or may have lost per-pixel alpha (e.g. JPEG only supports opaque
* pixels).
*
* @param format The format of the compressed image
* @param quality Hint to the compressor, 0-100. 0 meaning compress for
* small size, 100 meaning compress for max quality. Some
* formats, like PNG which is lossless, will ignore the
* quality setting
* @param stream The outputstream to write the compressed data.
* @return true if successfully compressed to the specified stream.
*/
@WorkerThread
public boolean compress(CompressFormat format, int quality, OutputStream stream)
一看,这货不是压缩的吗,且慢,再听我狡辩,你看这注释:
将位图的压缩版本写入指定的outputstream。如果返回true,则可以通过将相应的inputstream传递给BitmapFactory.decodeStream()来重构位图。注意:并不是所有格式都直接支持所有的位图配置,所以BitmapFactory返回的位图可能有不同的位深度,并且/或者可能丢失了每个像素的alpha值(例如JPEG只支持不透明像素)。
我们只看关键信息,它说它会将经过该方法压缩之后的压缩版位图,写入指定的输出流,然后,输出流又是我们指定的,意思是我们新建一个传参进去就好了,只要我们拿到输出流,也就相当于可以将输出流输出到指定目录下的文件了。然后是,格式CompressFormat:
/**
* Specifies the known formats a bitmap can be compressed into
*/
public enum CompressFormat {
JPEG (0),
PNG (1),
WEBP (2);
只见它给我们提供了3款产品,那我们选择png就好了,三者的区别就不在此展开了,其他有兴趣的自行搜索。 至于 quality 质量,直接传个100就好了,就是要纯种的。然后就完事了:
public void save() {
//创建一个文件用于存放图片
File file = new File(mContext.getExternalCacheDir() + "testSign.png");
if (file.exists()) {
file.delete();
}
OutputStream outputStream = null;
try {
//输出到这个文件
outputStream = new FileOutputStream(file);
} catch (FileNotFoundException e) {
e.printStackTrace();
Toast.makeText(mContext, "保存异常:" + e.getMessage(), Toast.LENGTH_SHORT).show();
}
//压缩形成输出流
mCacheBitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
Toast.makeText(mContext, "保存成功!", Toast.LENGTH_SHORT).show();
}
上述的mContext 可以使用构造方法里面的context
点击运行
然后,分别去模拟器和as自带的文件浏览器看那张图片
嗯,as里面看透明的背景,模拟器里面看,直接全黑了...
额,因为这个图片背景默认是透明的,所以,它的背景取决于浏览器的背景,我们需要设置一下它的背景,首先想一下,你心目中的画布背景是怎样的?应该是白色的吧?那很好,所以,我们只要将画布的染成或者说绘制成白色就好了,我们沿着draw开头的方法看看有什么发现...这一看,还挺多
虽然挺多的,不过讲真,我倒是相中了一个,谁,它!
/**
* Fill the entire canvas' bitmap (restricted to the current clip) with the specified color,
* using srcover porterduff mode.
*
* @param color the color to draw onto the canvas
*/
public void drawColor(@ColorInt int color) {
super.drawColor(color);
}
直译过来就是:使用srcover porterduff模式,用指定的颜色填充整个画布的位图(仅限于当前剪辑)。
嗯? srcover porterduff mode ?这是什么鬼....
不慌,我尝试在该类中搜索相关的字段,然后,我在这个方法的下方不远处发现了这个
进去看了看,然后,我顺藤摸瓜的找到了一些信息
这注释的直译意思是:源像素绘制在目标像素上。
这个 srcover porterduff mode 可以参考点击跳转的链接,也可参考扔物线的 https://hencoder.com/ui-1-2/
然后,我推测,算了 ,我推测不了,这些可能是 PorterDuff 里面内置的17种混合模式,这些混合模式用于2D图像的合成,不同混合模式有不同的合成效果,我当前选择的这个 drawColor 方法呢,它使用的是 PorterDuff 模式里面的 SRC_OVER 模式。这个模式的作用可能是(因为我也是瞎推测)将 drawColor 接收到的参数值,因为这个参数是color相关的嘛,就将这些color 的像素值绘制在目标像素,然后,目标像素这哥们会不会就是当前的画布canvas啊,那我试试,说不定是呢~ 那我们直接动手吧,在哪里动呢?我们现在要初始化画布的背景色,其实就是相当于先把画布绘制成白色,那可以直接在初始化 mCanvas 时,顺便绘制了就好了:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCacheBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mCacheBitmap);
mCanvas.drawColor(Color.WHITE);
}
然后直接运行看结果:
好了
再试试画点东西再保存:
开心!不行,这字写错了,我得重新写!:
哦豁,旧的笔迹怎么还在?
我的确按了清空的按钮,但是清空没有按我预期的流程走,为什么呢?我们回顾下清空的逻辑
/**
* 清空
*/
public void clear() {
Log.e(TAG, "clear()");
if (mPath != null) {
Log.e(TAG, "before clear mPath isEmpty => " + mPath.isEmpty());
mPath.reset();
Log.e(TAG, "after clear mPath isEmpty => " + mPath.isEmpty());
invalidate();
}
}
路径的确是被清空了,ondraw里面的 canvas 和 mCanvas ,用的是同一个路径,为什么 canvas 没了旧的痕迹,而和 mCanvas 关联在一起的位图,内容还是原来那样呢?因为 ondraw 之后,canvas 相当于重新使用当前的路径进行内容的绘制,而路径已经被清了,所以,canvas 也就绘制不出东西了,但是位图呢,它的内容由此至终都没改过,因为它的内容靠 mCanvas 绘制进去的嘛,但是清空方法里面,有对mCanvas 进行处理吗?并没有,所以,需要处理一下mCanvas,怎么处理它呢?毕竟,mCanvas 无法控制位图的对应方法,让位图实现个清除内容或者重置之类的操作,但是,mCanvas 可以绘制内容到位图上,那我们只要绘制内容覆盖原来的位图上的内容就好了,其实,说那么多,一行代码放在清空逻辑那里就好了:
在路径清空之后,直接绘制一片白色在位图上~
呈上MainActivity的完整代码:
package com.kabun.myapplication;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
public class MainActivity extends AppCompatActivity {
private SignView mSignView;
private Button mBtnClear;
private Button mBtnSave;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sign);
mSignView=findViewById(R.id.signView);
mBtnClear=findViewById(R.id.clear);
mBtnSave=findViewById(R.id.save);
mBtnClear.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(mSignView!=null){
mSignView.clear();
}
}
});
mBtnSave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(mSignView!=null){
mSignView.save();
}
}
});
}
}
呈上 SignView 的完整代码:
package com.kabun.myapplication;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.Nullable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.OutputStream;
public class SignView extends View {
private String TAG = this.getClass().getName();
private Path mPath = new Path();
private Paint mPaint = new Paint();
//位图的绘制内容输出者:画布
private Canvas mCanvas;
//用来存放绘制内容的位图
private Bitmap mCacheBitmap;
//上下文
private Context mContext;
public SignView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mContext = context;
mPaint.setStyle(Paint.Style.STROKE);
// Log.e(TAG, " mPaint.getStrokeWidth() = " + mPaint.getStrokeWidth());//默认描边宽度是0,但是真正绘制时依然有一个像素的宽度
// mPaint.setStrokeWidth(10);//设置描边宽度,也就是笔迹的粗细
Log.e(TAG, " mPaint.getStyle() = " + mPaint.getStyle());
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCacheBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mCacheBitmap);
mCanvas.drawColor(Color.WHITE);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mPath.moveTo(event.getX(), event.getY());
Log.e(TAG, "ACTION_DOWN getX = " + event.getX() + " getY = " + event.getY());
break;
case MotionEvent.ACTION_MOVE:
mPath.lineTo(event.getX(), event.getY());
Log.e(TAG, "ACTION_MOVE getX = " + event.getX() + " getY = " + event.getY());
break;
case MotionEvent.ACTION_UP:
Log.e(TAG, "ACTION_UP getX = " + event.getX() + " getY = " + event.getY());
break;
}
//真正负责绘制签名笔迹的画布,在这里接收路径mPath,以及事先定义好的颜料
mCanvas.drawPath(mPath, mPaint);
invalidate();
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.e(TAG, "onDraw canvas = " + canvas);
canvas.drawPath(mPath, mPaint);
}
/**
* 清空
*/
public void clear() {
Log.e(TAG, "clear()");
if (mPath != null) {
Log.e(TAG, "before clear mPath isEmpty => " + mPath.isEmpty());
mPath.reset();
mCanvas.drawColor(Color.WHITE);
Log.e(TAG, "after clear mPath isEmpty => " + mPath.isEmpty());
invalidate();
}
}
public void save() {
//创建一个文件用于存放图片
File file = new File(mContext.getExternalCacheDir() + "testSign.png");
if (file.exists()) {
file.delete();
}
OutputStream outputStream = null;
try {
//输出到这个文件
outputStream = new FileOutputStream(file);
} catch (FileNotFoundException e) {
e.printStackTrace();
Toast.makeText(mContext, "保存异常:" + e.getMessage(), Toast.LENGTH_SHORT).show();
}
//压缩形成输出流
mCacheBitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
Toast.makeText(mContext, "保存成功!", Toast.LENGTH_SHORT).show();
}
}
呈上完整的布局代码: