其實,手寫簽名,和畫圖有異曲同工之妙,
目錄
一、繪制筆跡
二、清除筆跡
三、保存筆跡
四、完善清除功能
那我們直接點,以畫圖作為說明參考,
一、繪制筆跡
首先,我們需要什么?畫布?然后,畫筆?不,我們需要先新建一個繼承于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);
}
}
同時,將它丟進布局里面,
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
tools:context=".MainActivity">
<com.kabun.myapplication.SignView
android:id="@+id/signView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>
點擊運行,哦豁,崩了,看下日志
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: <init> [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: <init> [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 <code>NoSuchMethodException</code> without a detail message.
*/
public NoSuchMethodException() {
super();
}
/**
* Constructs a <code>NoSuchMethodException</code> 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.
* <p>
* 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.
*
* <p>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();
}
}
呈上完整的布局代碼:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
tools:context=".MainActivity">
<com.kabun.myapplication.SignView
android:id="@+id/signView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<Button
android:id="@+id/clear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_margin="10dp"
android:text="清空"/>
<Button
android:id="@+id/save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_margin="10dp"
android:text="保存"/>
</RelativeLayout>
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/248630.html
標籤:其他
