Android学习笔记:自定义View之手写签名

其实,手写签名,和画图有异曲同工之妙。

目录

一、绘制笔迹

二、清除笔迹

三、保存笔迹

四、完善清除功能


那我们直接点,以画图作为说明参考。

一、绘制笔迹

首先,我们需要什么?画布?然后,画笔?不,我们需要先新建一个继承于View类的子类

Android学习笔记:自定义View之手写签名_第1张图片

我们先把它取名为  SignView.java  

 Android学习笔记:自定义View之手写签名_第2张图片

同时,你发现这玩意报红,提示什么呢

Android学习笔记:自定义View之手写签名_第3张图片

它提示说:View 里面,没有一个可用的默认构造函数,行,那我们给它实现便是了

按流程走到这里

Android学习笔记:自定义View之手写签名_第4张图片

我兴高采烈的选择了第一个,因为看上去参数少点嘛,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);
    }
}

看类注释,说的是,当找不到一个特定的方法时,就会抛出来,额,好难...

Android学习笔记:自定义View之手写签名_第5张图片

所以是哪个方法???看样子,像是初始化时,找不到有两个参数的方法,莫非是一开始的这个,

Android学习笔记:自定义View之手写签名_第6张图片

试试

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

Android学习笔记:自定义View之手写签名_第7张图片

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 坐标都现世了!!

Android学习笔记:自定义View之手写签名_第8张图片

接下来要干嘛?按国标来走的话,应该是在上述一系列的xy点位置上,对应地绘制出一系列的点,那么,当这些点连起来的时候,轨迹就出来了。

那么,问题又来了,谁去使用这些坐标点呢?或者说,这些坐标点传给谁去处理呢?传给一个 叫 Path 的实例,没错,翻译过来意思就是路径,挺实在一娃,你想想,这么多个坐标点,最理想的情况当然是有那么一个东西或者工具,帮你把它们连起来显示在屏幕上啦,对不对?很好,Path 能帮你做到这些,它能很好地帮你把这些点连起来,但是,Path 从哪里来的呢?new 出来的

private Path mPath =new Path();

不过要注意,导入正确的包,导入的是    android.graphics.Path

Android学习笔记:自定义View之手写签名_第9张图片

好了,怎么使用它呢?俗话说,两点连成一条线,也就是说,应该是要有序地从某个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)吧

Android学习笔记:自定义View之手写签名_第10张图片

那就试试嘛~

我们直接将手指在屏幕上滑动时:也就是上述提到的当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;
    }

这样就可以了吗?跑跑试试?

Android学习笔记:自定义View之手写签名_第11张图片

怎么样?啥也没有对不对?先听我狡辩,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..就会有代码提示了

Android学习笔记:自定义View之手写签名_第12张图片

此时,选择第一个就是了,默认实现代码如下

    @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不为空

然后,我们试着划几下

Android学习笔记:自定义View之手写签名_第13张图片

你会发现,没啥东西在屏幕上出来,再看下日志

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启动的时候被调用了一次,后面就没动静了....就挺尴尬的...

 Android学习笔记:自定义View之手写签名_第14张图片

 

  所以,我们该怎样才能让 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() 将会被调用,是不是就意味着画布就绘制出我们期望放置的内容了?行,试试

Android学习笔记:自定义View之手写签名_第15张图片

我选择在  onTouchEvent  返回值之前,每次处理完手势事件坐标点之后,进行画布的绘制方法调用,运行效果

Android学习笔记:自定义View之手写签名_第16张图片

 

再看下日志

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() 在每次滑动之后,都被成功地调用了,同时,画布终于有痕迹了

Android学习笔记:自定义View之手写签名_第17张图片

不过,话说回来,这画迹有点诡异,但是依稀看出画出的这一坨黑色里面有那么一丝丝规律的,就是我手势去到哪,这坨黑色就在哪蹦跶。这又抛出了一个新问题了,为什么会呈现这般模样呢?既然是样子的问题,那么就追究到底,样子问题归谁管?样子问题说白了就是样式问题,谁管的样式?还记得这个绘制它出来的 drawPath 方法吗?

Android学习笔记:自定义View之手写签名_第18张图片

请看红框标示的内容,以及之前的注释描述 路径将根据颜料中的样式被填充或加框 

如此看来,这次的样式问题,颜料paint全责。 注意注释里的这句:  the Style in the paint  说不定,在paint 类里面,可以找找跟style相关的方法

Android学习笔记:自定义View之手写签名_第19张图片

你看,这是不是style

    Android学习笔记:自定义View之手写签名_第20张图片

我们看看这个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:
 

Android学习笔记:自定义View之手写签名_第21张图片

你看!!笔迹是不是出来了?!

Android学习笔记:自定义View之手写签名_第22张图片

再看下日志

2021-01-10 10:56:41.869 2456-2456/com.kabun.myapplication E/com.kabun.myapplication.SignView:  mPaint.getStyle() = STROKE

颜料的样式已经成功改为描边了。不过是否留意到,我开始画的地方和笔迹的起始地方对不上?

Android学习笔记:自定义View之手写签名_第23张图片

标示 1 的地方是我触摸的位置,也是期望笔迹的开始的位置,但真正的笔迹开始位置是 标示 2 的地方.......(这现象告诉我一个道理,期望与现实之间的落差,只会迟到,不会不到。)为什么呢?路径的问题可以回头找下路径解决,是否还记得我们刚对这个lineTo方法的分析:

Android学习笔记:自定义View之手写签名_第24张图片

现在试出结果了,在我们没有手动设置好起点的话,第一个点确实被初始化为 (0,0)了,然后,我们手指落下的点,自然地接到了起点  (0,0)也就是屏幕左上方的坐标点的连线痕迹,所以,破案。那么, 按照注释所说,我们只要在生成第一个点的时候,调用 moveTo 方法去设置好起点即可。那么,怎样才算是第一个点呢?换句话问就是,moveTo 这个方法要放在哪里去调用才合适?按道理说,路径的起点应该是笔迹的起点,也就是手指每次落下的那个位置,也就是对应着手势事件中的 MotionEvent 的ACTION_DOWN 类型。理论是这样的,我们实践一下:

Android学习笔记:自定义View之手写签名_第25张图片

           补了一发代码,使得每次接触事件产生的时候,设置对应的笔迹起点,跑一下

Android学习笔记:自定义View之手写签名_第26张图片

看,是不是就挺像那么一回事了~

二、清除笔迹

不过,你也发现了..这字...好像.....多了点东西

Android学习笔记:自定义View之手写签名_第27张图片

尴尬,我想清空内容重新写,怎么清空呢?将app重启?不,使不得,优雅点,换台手机吧,开个玩笑。既然内容是由画布联合路径,加上颜料的点缀,通过画布自身的方法绘制出来的,那么,解铃还须系铃人,这三位之中,应该找谁买单?

画布吗?可以看下画布自身有没有跟还原或者清空之类的相关方法,或者直接点,绘制一片跟屏幕原始颜色一样的白色,不也一样效果么,屏幕变回一片白色不就好了~但是,我要说但是了,画布是在ondraw方法里传过来的, 也就是说,每次都要刷新完绘制之后,才能拿到画布canvas这货,即使,你拿到它的引用,赋予给一个画布变量,通过画布清空了内容,但是,刷新时,该干嘛还是干嘛,如果说通过设置标志位进行判断的话,会不会有点麻烦呢哎下一个

那路径呢?目前来看,路径主要是收集坐标点信息,然后将整个自身丢给画布了,我很好奇,如果我可以删掉它里面的那一堆坐标点信息,或者说,将path还原了之后,再丢给画布,画布能不能继续蹦跶?

至于颜料,目前我只是设置了颜料的样式,然后就将颜料递给了画布大哥了,不妥,这货怼不得,无从下手

我太难了....

Android学习笔记:自定义View之手写签名_第28张图片

行吧,先尝试从路径下手,目前,我们只是使用了路径的两个方法,分别是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();
        }
    }

Android学习笔记:自定义View之手写签名_第29张图片

看,有效的,这法子行得通,再看下日志

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

       点击运行

              

Android学习笔记:自定义View之手写签名_第30张图片

 然后,分别去模拟器和as自带的文件浏览器看那张图片

Android学习笔记:自定义View之手写签名_第31张图片

嗯,as里面看透明的背景,模拟器里面看,直接全黑了...

额,因为这个图片背景默认是透明的,所以,它的背景取决于浏览器的背景,我们需要设置一下它的背景,首先想一下,你心目中的画布背景是怎样的?应该是白色的吧?那很好,所以,我们只要将画布的染成或者说绘制成白色就好了,我们沿着draw开头的方法看看有什么发现...这一看,还挺多

Android学习笔记:自定义View之手写签名_第32张图片

虽然挺多的,不过讲真,我倒是相中了一个,谁,它!

    /**
     * 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  ?这是什么鬼....

Android学习笔记:自定义View之手写签名_第33张图片

 

不慌,我尝试在该类中搜索相关的字段,然后,我在这个方法的下方不远处发现了这个

Android学习笔记:自定义View之手写签名_第34张图片

进去看了看,然后,我顺藤摸瓜的找到了一些信息

Android学习笔记:自定义View之手写签名_第35张图片

这注释的直译意思是:源像素绘制在目标像素上。

这个 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);
    }

然后直接运行看结果:

Android学习笔记:自定义View之手写签名_第36张图片

好了

再试试画点东西再保存:

 

四、完善清除功能

开心!不行,这字写错了,我得重新写!:

 

哦豁,旧的笔迹怎么还在?

Android学习笔记:自定义View之手写签名_第37张图片

我的确按了清空的按钮,但是清空没有按我预期的流程走,为什么呢?我们回顾下清空的逻辑

    /**
     * 清空
     */
    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 可以绘制内容到位图上,那我们只要绘制内容覆盖原来的位图上的内容就好了,其实,说那么多,一行代码放在清空逻辑那里就好了:

Android学习笔记:自定义View之手写签名_第38张图片

在路径清空之后,直接绘制一片白色在位图上~

 

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

}

呈上完整的布局代码:




    

    

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(自定义view,android,安卓)