主頁 > 移動端開發 > Android學習筆記:自定義View之手寫簽名

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

2021-01-14 13:00:56 移動端開發

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

目錄

一、繪制筆跡

二、清除筆跡

三、保存筆跡

四、完善清除功能


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

一、繪制筆跡

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

標籤:其他

上一篇:用CDN方式引入Vue、Axios兼容IE瀏覽器的ES6語法處理

下一篇:Android Binder通信一次拷貝你真的理解了嗎?

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【從零開始擼一個App】Dagger2

    Dagger2是一個IOC框架,一般用于Android平臺,第一次接觸的朋友,一定會被搞得暈頭轉向。它延續了Java平臺Spring框架代碼碎片化,注解滿天飛的傳統。嘗試將各處代碼片段串聯起來,理清思緒,真不是件容易的事。更不用說還有各版本細微的差別。 與Spring不同的是,Spring是通過反射 ......

    uj5u.com 2020-09-10 06:57:59 more
  • Flutter Weekly Issue 66

    新聞 Flutter 季度調研結果分享 教程 Flutter+FaaS一體化任務編排的思考與設計 詳解Dart中如何通過注解生成代碼 GitHub 用對了嗎?Flutter 團隊分享如何管理大型開源專案 插件 flutter-bubble-tab-indicator A Flutter librar ......

    uj5u.com 2020-09-10 06:58:52 more
  • Proguard 常用規則

    介紹 Proguard 入口,如何查看輸出,如何使用 keep 設定入口以及使用實體,如何配置壓縮,混淆,校驗等規則。

    ......

    uj5u.com 2020-09-10 06:59:00 more
  • Android 開發技術周報 Issue#292

    新聞 Android即將獲得類AirDrop功能:可向附近設備快速分享檔案 谷歌為安卓檔案管理應用引入可安全隱藏資料的Safe Folder功能 Android TV新主界面將顯示電影、電視節目和應用推薦內容 泄露的Android檔案暗示了傳說中的谷歌Pixel 5a與折疊屏新機 谷歌發布Andro ......

    uj5u.com 2020-09-10 07:00:37 more
  • AutoFitTextureView Error inflating class

    報錯: Binary XML file line #0: Binary XML file line #0: Error inflating class xxx.AutoFitTextureView 解決: <com.example.testy2.AutoFitTextureView android: ......

    uj5u.com 2020-09-10 07:00:41 more
  • 根據Uri,Cursor沒有獲取到對應的屬性

    Android: 背景:呼叫攝像頭,拍攝視頻,指定保存的地址,但是回傳的Cursor檔案,只有名稱和大小的屬性,沒有其他諸如時長,連ID屬性都沒有 使用 cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATIO ......

    uj5u.com 2020-09-10 07:00:44 more
  • Android連載29-持久化技術

    一、持久化技術 我們平時所使用的APP產生的資料,在記憶體中都是瞬時的,會隨著斷電、關機等丟失資料,因此android系統采用了持久化技術,用于存盤這些“瞬時”資料 持久化技術包括:檔案存盤、SharedPreference存盤以及資料庫存盤,還有更復雜的SD卡記憶體儲。 二、檔案存盤 最基本存盤方式, ......

    uj5u.com 2020-09-10 07:00:47 more
  • Android Camera2Video整合到自己專案里

    背景: Android專案里呼叫攝像頭拍攝視頻,原本使用的 MediaStore.ACTION_VIDEO_CAPTURE, 后來因專案需要,改成了camera2 1.Camera2Video 官方demo有點問題,下載后,不能直接整合到專案 問題1.多次拍攝視頻崩潰 問題2.雙擊record按鈕, ......

    uj5u.com 2020-09-10 07:00:50 more
  • Android 開發技術周報 Issue#293

    新聞 谷歌為Android TV開發者提供多種新功能 Android 11將自動填表功能整合到鍵盤輸入建議中 谷歌宣布Android Auto即將支持更多的導航和數字停車應用 谷歌Pixel 5只有XL版本 搭載驍龍765G且將比Pixel 4更便宜 [圖]Wear OS將迎來重磅更新:應用啟動時間 ......

    uj5u.com 2020-09-10 07:01:38 more
  • 海豚星空掃碼投屏 Android 接收端 SDK 集成 六步驟

    掃碼投屏,開放網路,獨占設備,不需要額外下載軟體,微信掃碼,發現設備。支持標準DLNA協議,支持倍速播放。視頻,音頻,圖片投屏。好點意思。還支持自定義基于 DLNA 擴展的操作動作。好像要收費,沒體驗。 這里簡單記錄一下集成程序。 一 跟目錄的build.gradle添加私有mevan倉庫 mave ......

    uj5u.com 2020-09-10 07:01:43 more
最新发布
  • 歡迎頁輪播影片

    如圖,引導開始,球從上落下,同時淡入文字,然后文字開始輪播,最后一頁時停止,點擊進入首頁。 在來看看效果圖。 重力球先不講,主要歡迎輪播簡單實作 首先新建一個類 TextTranslationXGuideView,用于影片展示 文本是類似的,最后會有個圖片箭頭影片,布局很簡單,就是一個 TextVi ......

    uj5u.com 2023-04-20 08:40:31 more
  • 【FAQ】關于華為推送服務因營銷訊息頻次管控導致服務通訊類訊息

    一. 問題描述 使用華為推送服務下發IM訊息時,下發訊息請求成功且code碼為80000000,但是手機總是收不到訊息; 在華為推送自助分析(Beta)平臺查看發現,訊息發送觸發了頻控。 二. 問題原因及背景 2023年1月05日起,華為推送服務對咨詢營銷類訊息做了單個設備每日推送數量上限管理,具體 ......

    uj5u.com 2023-04-20 08:40:11 more
  • 歡迎頁輪播影片

    如圖,引導開始,球從上落下,同時淡入文字,然后文字開始輪播,最后一頁時停止,點擊進入首頁。 在來看看效果圖。 重力球先不講,主要歡迎輪播簡單實作 首先新建一個類 TextTranslationXGuideView,用于影片展示 文本是類似的,最后會有個圖片箭頭影片,布局很簡單,就是一個 TextVi ......

    uj5u.com 2023-04-20 08:39:36 more
  • 【FAQ】關于華為推送服務因營銷訊息頻次管控導致服務通訊類訊息

    一. 問題描述 使用華為推送服務下發IM訊息時,下發訊息請求成功且code碼為80000000,但是手機總是收不到訊息; 在華為推送自助分析(Beta)平臺查看發現,訊息發送觸發了頻控。 二. 問題原因及背景 2023年1月05日起,華為推送服務對咨詢營銷類訊息做了單個設備每日推送數量上限管理,具體 ......

    uj5u.com 2023-04-20 08:39:13 more
  • iOS從UI記憶體地址到讀取成員變數(oc/swift)

    開發除錯時,我們發現bug時常首先是從UI顯示發現例外,下一步才會去定位UI相關連的資料的。XCode有給我們提供一系列debug工具,但是很多人可能還沒有形成一套穩定的除錯流程,因此本文嘗試解決這個問題,順便提出一個暴論:UI顯示例外問題只需要兩個步驟就能完成定位作業的80%: 定位例外 UI 組 ......

    uj5u.com 2023-04-19 09:16:23 more
  • FIDE重磅更新!性能飛躍!體驗有禮!

    FIDE 開發者工具重構升級啦!實作500%性能提升,誠邀體驗! 一直以來不少開發者朋友在社區反饋,在使用 FIDE 工具的程序中,時常會遇到諸如加載不及時、代碼預覽/渲染性能不如意的情況,十分影響開發體驗。 作為技術團隊,我們深知一件趁手的開發工具對開發者的重要性,因此,在2023年開年,FinC ......

    uj5u.com 2023-04-19 09:16:15 more
  • 游戲內嵌社區服務開放,助力開發者提升玩家互動與留存

    華為 HMS Core 游戲內嵌社區服務提供快速訪問華為游戲中心論壇能力,支持玩家直接在游戲內瀏覽帖子和交流互動,助力開發者擴展內容生產和觸達的場景。 一、為什么要游戲內嵌社區? 二、游戲內嵌社區的典型使用場景 1、游戲內打開論壇 您可以在游戲內繪制論壇入口,為玩家提供沉浸式發帖、瀏覽、點贊、回帖、 ......

    uj5u.com 2023-04-19 09:15:46 more
  • iOS從UI記憶體地址到讀取成員變數(oc/swift)

    開發除錯時,我們發現bug時常首先是從UI顯示發現例外,下一步才會去定位UI相關連的資料的。XCode有給我們提供一系列debug工具,但是很多人可能還沒有形成一套穩定的除錯流程,因此本文嘗試解決這個問題,順便提出一個暴論:UI顯示例外問題只需要兩個步驟就能完成定位作業的80%: 定位例外 UI 組 ......

    uj5u.com 2023-04-19 09:14:53 more
  • FIDE重磅更新!性能飛躍!體驗有禮!

    FIDE 開發者工具重構升級啦!實作500%性能提升,誠邀體驗! 一直以來不少開發者朋友在社區反饋,在使用 FIDE 工具的程序中,時常會遇到諸如加載不及時、代碼預覽/渲染性能不如意的情況,十分影響開發體驗。 作為技術團隊,我們深知一件趁手的開發工具對開發者的重要性,因此,在2023年開年,FinC ......

    uj5u.com 2023-04-19 09:14:08 more
  • 游戲內嵌社區服務開放,助力開發者提升玩家互動與留存

    華為 HMS Core 游戲內嵌社區服務提供快速訪問華為游戲中心論壇能力,支持玩家直接在游戲內瀏覽帖子和交流互動,助力開發者擴展內容生產和觸達的場景。 一、為什么要游戲內嵌社區? 二、游戲內嵌社區的典型使用場景 1、游戲內打開論壇 您可以在游戲內繪制論壇入口,為玩家提供沉浸式發帖、瀏覽、點贊、回帖、 ......

    uj5u.com 2023-04-19 09:08:34 more