Android學習筆記:自定義View之手寫簽名

其實,手寫簽名,和畫圖有異曲同工之妙。

目錄

一、繪製筆跡

二、清除筆跡

三、保存筆跡

四、完善清除功能


那我們直接點,以畫圖作爲說明參考。

一、繪製筆跡

首先,我們需要什麼?畫布?然後,畫筆?不,我們需要先新建一個繼承於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>

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章