安卓畫筆筆鋒的實現探索(二)

  • 本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家發布

1、下圖的效果的實現看這篇文章:http://www.lxweimin.com/p/6746d68ef2c3

微信圖片_20171018135736.jpg

2、水彩筆效果一

//不設置
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
//使用原圖的資源文件為
mOriginBitmap = BitmapFactory.decodeResource( mContext.getResources(), R.mipmap.brush);
微信圖片_20171018135732.jpg

3、水彩筆效果二,使用原圖的資源文件為R.mipmap.cicrle

微信圖片_20171018135726.jpg

4、水彩筆效果三,使用原圖的資源文件為R.mipmap.tranglie

微信圖片_20171018135715.jpg

5、設置paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));的效果

微信圖片_20171018143623.jpg

如何實現的細節在上篇文章有很仔細的說明,可先看上篇文章

image.png

1.代碼上我抽取了一個基類,主要是方便后面的擴展

/**
 * @author shiming
 * @version v1.0 create at 2017/10/17
 * @des 處理draw和touch事件的基類
 */
public abstract class BasePen {

    /**
     * 繪制
     *
     * @param canvas
     */
    public abstract  void draw(Canvas canvas);

    /**
     * 接受并處理onTouchEvent
     *
     * @param event
     * @return
     */
    public  boolean onTouchEvent(MotionEvent event,Canvas canvas){
         return false;
     }
}

2、關于BasePenExtend集成BasePen

draw方法的抽取,和上篇文章一樣,當接觸到的點很少的時候,不用去繪制,由于在實現水彩筆的時候,使用一只筆的時候會導致整個畫布的筆的透明度發生改變,所以提供了一個抽象的方法讓子類去實現,SteelPen和BrushPen中唯一在draw的差別就是多了一只paint。

 @Override
    public void draw(Canvas canvas) {
        mPaint.setStyle(Paint.Style.FILL);
        //點的集合少 不去繪制
        if (mHWPointList == null || mHWPointList.size() < 1)
            return;
        //當控制點的集合很少的時候,需要畫個小圓,但是需要算法
        if (mHWPointList.size() < 2) {
            ControllerPoint point = mHWPointList.get(0);
            //由于此問題在算法上還沒有實現,所以暫時不給他畫圓圈
            //canvas.drawCircle(point.x, point.y, point.width, mPaint);
        } else {
            mCurPoint = mHWPointList.get(0);
            drawNeetToDo(canvas);
        }
    }

   /**
     * 這里由于在設置筆的透明度,會導致整個線,或者說整個畫布的的顏透明度隨著整個筆的透明度而變化,
     * 所以在這里考慮是不是說,繪制毛筆的時候,每次都給它new 一個paint ,但是這里我還沒有找到更好的辦法
     *
     * @param canvas
     */
    // TODO: 2017/10/17  這個問題  待解決
    protected abstract void drawNeetToDo(Canvas canvas);

onTouchEvent事件在NewDrawPenView調用,這里實現就可以了,注意這個問題就行,event會被下一次事件重用,這里必須生成新的,否則會有問題

   @Override
    public boolean onTouchEvent(MotionEvent event,Canvas canvas) {
        // event會被下一次事件重用,這里必須生成新的,否則會有問題
        MotionEvent event2 = MotionEvent.obtain(event);
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                onDown(createMotionElement(event2));
                return true;
            case MotionEvent.ACTION_MOVE:
                onMove(createMotionElement(event2));
                return true;
            case MotionEvent.ACTION_UP:
                onUp(createMotionElement(event2),canvas);
                return true;
            default:
                break;
        }
        return super.onTouchEvent(event,canvas);
    }

onDown事件的實現,由于在自定義View中,MotionEvent.ACTION_DOWN:事件的觸發要比onDraw早,當實現水彩筆的時候,我在想每次能不能使用一只新的筆,那么改變筆的透明度的時候,畫布其他上上的筆畫的就不會發生改變,所以暴露了一個方法,讓子類去實現,不從寫的話,這個筆就會為null!


    /**
     * 按下的事件
     * @param mElement
     */
    public void onDown(MotionElement mElement){
        if (mPaint==null){
            throw new NullPointerException("paint 筆不可能為null哦");
        }
        if (getNewPaint(mPaint)!=null){
            Paint paint=getNewPaint(mPaint);
            mPaint=paint;
            //當然了,不要因為擔心內存泄漏,在每個變量使用完成后都添加xxx=null,
            // 對于消除過期引用的最好方法,就是讓包含該引用的變量結束生命周期,而不是顯示的清空
            paint=null;
            System.out.println("shiming 當繪制的時候是否為新的paint"+mPaint+"原來的對象是否銷毀了paint=="+paint);
        }
        mPointList.clear();
        //如果在brush字體這里接受到down的事件,把下面的這個集合清空的話,那么繪制的內容會發生改變
        //不清空的話,也不可能
        mHWPointList.clear();
        //記錄down的控制點的信息
        ControllerPoint curPoint = new ControllerPoint(mElement.x, mElement.y);
        //如果用筆畫的畫我的屏幕,記錄他寬度的和壓力值的乘,但是哇,
        if (mElement.tooltype == MotionEvent.TOOL_TYPE_STYLUS) {
            mLastWidth = mElement.pressure * mBaseWidth;
        } else {
            //如果是手指畫的,我們取他的0.8
            mLastWidth = 0.8 * mBaseWidth;
        }
        //down下的點的寬度
        curPoint.width = (float) mLastWidth;
        mLastVel = 0;
        mPointList.add(curPoint);
        //記錄當前的點
        mLastPoint = curPoint;
    }

  protected  Paint getNewPaint(Paint paint){
        return null;
    }

onMove事件其實實現原理和上篇文章一樣,但是呢,當繪制水彩筆的時候,有個透明度的關系,所以需要交給子類去實現.

 /**
     * 手指移動的事件
     * @param mElement
     */
    public void onMove(MotionElement mElement){

        ControllerPoint curPoint = new ControllerPoint(mElement.x, mElement.y);
        double deltaX = curPoint.x - mLastPoint.x;
        double deltaY = curPoint.y - mLastPoint.y;
        //deltaX和deltay平方和的二次方根 想象一個例子 1+1的平方根為1.4 (x2+y2)開根號
        double curDis = Math.hypot(deltaX, deltaY);
        //我們求出的這個值越小,畫的點或者是繪制橢圓形越多,這個值越大的話,繪制的越少,筆就越細,寬度越小
        double curVel = curDis * IPenConfig.DIS_VEL_CAL_FACTOR;
        double curWidth;
        //點的集合少,我們得必須改變寬度,每次點擊的down的時候,這個事件
        if (mPointList.size() < 2) {
            if (mElement.tooltype == MotionEvent.TOOL_TYPE_STYLUS) {
                curWidth = mElement.pressure * mBaseWidth;
            } else {
                curWidth = calcNewWidth(curVel, mLastVel, curDis, 1.5,
                        mLastWidth);
            }
            curPoint.width = (float) curWidth;
            mBezier.init(mLastPoint, curPoint);
        } else {
            mLastVel = curVel;
            if (mElement.tooltype == MotionEvent.TOOL_TYPE_STYLUS) {
                curWidth = mElement.pressure * mBaseWidth;
            } else {
                //由于我們手機是觸屏的手機,滑動的速度也不慢,所以,一般會走到這里來
                //闡明一點,當滑動的速度很快的時候,這個值就越小,越慢就越大,依靠著mlastWidth不斷的變換
                curWidth = calcNewWidth(curVel, mLastVel, curDis, 1.5,
                        mLastWidth);
            }
            curPoint.width = (float) curWidth;
            mBezier.addNode(curPoint);
        }
        //每次移動的話,這里賦值新的值
        mLastWidth = curWidth;

        mPointList.add(curPoint);
        moveNeetToDo(curDis);
        mLastPoint = curPoint;
    }


    /**
     * 移動的時候,這里由于需要透明度的處理,交給子類
     * @param
     */
    protected abstract void moveNeetToDo(double f);

在上篇文章中我們有詳細介紹calcNewWidth()方法的作用:當滑動的速度很快的時候,這個值就越小,越慢就越大,依靠著mlastWidth不斷的變換,如何變化的呢?在代碼中做了很詳細的解釋

 public double calcNewWidth(double curVel, double lastVel, double curDis,
                                double factor, double lastWidth) {
        double calVel = curVel * 0.6 + lastVel * (1 - 0.6);
        //返回指定數字的自然對數
        //手指滑動的越快,這個值越小,為負數
        double vfac = Math.log(factor * 2.0f) * (-calVel);
        //此方法返回值e,其中e是自然對數的基數。
        //Math.exp(vfac) 變化范圍為0 到1 當手指沒有滑動的時候 這個值為1 當滑動很快的時候無線趨近于0
        //在次說明下,當手指抬起來,這個值會變大,這也就說明,抬起手太慢的話,筆鋒效果不太明顯
        //這就說明為什么筆鋒的效果不太明顯
        double calWidth = mBaseWidth * Math.exp(vfac);
        //滑動的速度越快的話,mMoveThres也越大
        double mMoveThres = curDis * 0.01f;
        //對之值最大的地方進行控制
        if (mMoveThres > IPenConfig.WIDTH_THRES_MAX) {
            mMoveThres = IPenConfig.WIDTH_THRES_MAX;
        }
        //滑動的越快的話,第一個判斷會走
        if (Math.abs(calWidth - mBaseWidth) / mBaseWidth > mMoveThres) {

            if (calWidth > mBaseWidth) {
                calWidth = mBaseWidth * (1 + mMoveThres);
            } else {
                calWidth = mBaseWidth * (1 - mMoveThres);
            }
            //滑動的越慢的話,第二個判斷會走
        } else if (Math.abs(calWidth - lastWidth) / lastWidth > mMoveThres) {

            if (calWidth > lastWidth) {
                calWidth = lastWidth * (1 + mMoveThres);
            } else {
                calWidth = lastWidth * (1 - mMoveThres);
            }
        }
        return calWidth;
    }

最后一步繪制,只需要要判斷現在的點和觸摸點的位置一樣就不用去繪制,其余的交個子類去實現即可,在這里我有一個問題沒有解決,在onDown事件下,就是一直按著屏幕某個點的時候,這個方法會一直走,意思就是說,用戶的行為沒有發生繪制筆,但是呢,代碼一直在繪制,不斷重復的繪制,可能在性能上不太友好!還需優化

    /**
     * 當現在的點和觸摸點的位置在一起的時候不用去繪制
     * @param canvas
     * @param point
     * @param paint
     */
    protected void drawToPoint(Canvas canvas, ControllerPoint point, Paint paint) {
        if ((mCurPoint.x == point.x) && (mCurPoint.y == point.y)) {
            return;
        }
        //毛筆的效果和鋼筆的不太一樣,交給自己去實現
        doNeetToDo(canvas,point,paint);
    }

關于BrushPen

初始化是從資源文件中獲取bitmap,在我個人的測試中,當資源文件如果形狀很多種的時候,會構建很多中的筆畫的效果,非常有意思,如上面的圖例

  public BrushPen(Context context) {
        super(context);
        initTexture();
    }
   /**
     * 感謝公司的ui大哥  小伍哥 免費給的切圖
     * R.mipmap.tranglie 設置的時候有點像三角形的筆鋒
     * R.mipmap.cicrle    圓形的筆鋒效果
     * R.mipmap.six        六邊形有點怪怪的,可以測試一下
     * R.drawable.brush  這個才是用起來比較舒服,如果你的筆鋒要很尖的話,叫ui爸爸給你裁剪這種圖 越尖越好
     */
    private void initTexture() {
        //通過資源文件生成的原始的bitmap區域 后面的資源圖有些更加有意識的東西
        mOriginBitmap = BitmapFactory.decodeResource(
                mContext.getResources(), R.mipmap.brush);
    }

setBitmap(bitmap)方法,主要是得到需要繪制的rect的區域,這個沒什么,一些列的操作的時候,但是這里有個很有趣的現象, paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));以下的話源自網絡不是我說的,哈哈:PorterDuffXfermode其實就是簡單的圖形交并集計算,比如重疊的部分刪掉或者疊加等等,事實上呢!PorterDuffXfermode的計算絕非是根據于此!如下圖所示,最常見的應用就是蒙板繪制,利用源圖作為蒙板“摳出”目標圖上的圖像。

image.png

Xfermode國外有大神稱之為過渡模式,這種翻譯比較貼切但恐怕不易理解,大家也可以直接稱之為圖像混合模式,這是我在上篇文章介紹的,拋磚引玉下,地址:http://www.cnblogs.com/tianzhijiexian/p/4297172.html

    /**
     * 主要是得到需要繪制的rect的區域
     * @param bitmap
     */
    private void setBitmap(Bitmap bitmap) {
        Canvas canvas = new Canvas();
        mBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(),
                Bitmap.Config.ARGB_8888);
        //用指定的方式填充位圖的像素。
        mBitmap.eraseColor(Color.rgb(Color.red(mPaint.getColor()),
                Color.green(mPaint.getColor()), Color.blue(mPaint.getColor())));
        //用畫布制定位圖繪制
        canvas.setBitmap(mBitmap);
        Paint paint = new Paint();
        //如果把這行代碼注釋掉這里生成的東西更加有意思
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
        canvas.drawBitmap(bitmap, 0, 0, paint);

        //src 代表需要繪制的區域
        mOldRect.set(0, 0, mBitmap.getWidth()/4, mBitmap.getHeight()/4);
    }

根據筆的寬度的變化,筆的透明度要和發生變化

    /**
     * 更具筆的寬度的變化,筆的透明度要和發生變化
     * @param point
     * @return
     */
    private ControllerPoint getWithPointAlphaPoint(ControllerPoint point) {
        ControllerPoint nPoint = new ControllerPoint();
        nPoint.x = point.x;
        nPoint.y = point.y;
        nPoint.width = point.width;
        int alpha = (int) (255 * point.width / mBaseWidth / 2);
        if (alpha < 10) {
            alpha = 10;
        } else if (alpha > 255) {
            alpha = 255;
        }
        nPoint.alpha = alpha;
        return nPoint;
    }

這里才是關鍵的地方,原理就是不斷的繪制Bitmap,通過Bitmap構建成為一根線,同時繪制區域的大小,直接導致筆的寬度的變化


 protected void drawLine(Canvas canvas, double x0, double y0, double w0,
                            int a0, double x1, double y1, double w1, int a1, Paint paint) {
        double curDis = Math.hypot(x0 - x1, y0 - y1);
        int factor = 2;
        if (paint.getStrokeWidth() < 6) {
            factor = 1;
        } else if (paint.getStrokeWidth() > 60) {
            factor = 3;
        }
        int steps = 1 + (int) (curDis / factor);
        double deltaX = (x1 - x0) / steps;
        double deltaY = (y1 - y0) / steps;
        double deltaW = (w1 - w0) / steps;
        double deltaA = (a1 - a0) / steps;
        double x = x0;
        double y = y0;
        double w = w0;
        double a = a0;

        for (int i = 0; i < steps; i++) {
            if (w < 1.5)
                w = 1.5;
            //根據點的信息計算出需要把bitmap繪制在什么地方
            mNeedDrawRect.set((float) (x - w / 2.0f), (float) (y - w / 2.0f),
                    (float) (x + w / 2.0f), (float) (y + w / 2.0f));
            //每次到這里來的話,這個筆的透明度就會發生改變,但是呢,這個筆不用同一個的話,有點麻煩
            //我在這里做了個不是辦法的辦法,每次呢?我都從新new了一個新的筆,每次循環就new一個,內存就有很多的筆了
            //這里new 新的筆  我放到外面去做了
            //Paint newPaint = new Paint(paint);
            //當這里很小的時候,透明度就會很小,個人測試在3.0左右比較靠譜
            paint.setAlpha((int) (a / 3.0f));
            //第一個Rect 代表要繪制的bitmap 區域,第二個 Rect 代表的是要將bitmap 繪制在屏幕的什么地方
            canvas.drawBitmap(mBitmap, mOldRect, mNeedDrawRect, paint);
            x += deltaX;
            y += deltaY;
            w += deltaW;
            a += deltaA;
        }
    }

關于SteelPen,由于在上篇文章已經很詳細的介紹了,這里就不在多少,需要的話,移步上篇文章:http://www.lxweimin.com/p/6746d68ef2c3

package com.shiming.pen.new_code;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;

import com.shiming.pen.old_code.ControllerPoint;

import static com.shiming.pen.new_code.IPenConfig.STEPFACTOR;

/**
 * @author shiming
 * @version v1.0 create at 2017/10/17
 * @des 鋼筆
 */
public class SteelPen extends BasePenExtend {

    public SteelPen(Context context) {
        super(context);
    }

    @Override
    protected void drawNeetToDo(Canvas canvas) {
        for (int i = 1; i < mHWPointList.size(); i++) {
            ControllerPoint point = mHWPointList.get(i);
            drawToPoint(canvas, point, mPaint);
            mCurPoint = point;
        }
    }

    @Override
    protected void moveNeetToDo(double curDis) {
        int steps = 1 + (int) curDis / STEPFACTOR;
        double step = 1.0 / steps;
        for (double t = 0; t < 1.0; t += step) {
            ControllerPoint point = mBezier.getPoint(t);
            mHWPointList.add(point);
        }
    }

    @Override
    protected void doNeetToDo(Canvas canvas, ControllerPoint point, Paint paint) {
        drawLine(canvas, mCurPoint.x, mCurPoint.y, mCurPoint.width, point.x,
                point.y, point.width, paint);
    }

    /**
     * 其實這里才是關鍵的地方,通過畫布畫橢圓,每一個點都是一個橢圓,這個橢圓的所有細節,逐漸構建出一個完美的筆尖
     * 和筆鋒的效果,我覺得在這里需要大量的測試,其實就對低端手機進行排查,看我們繪制的筆的寬度是多少,繪制多少個橢圓
     * 然后在低端手機上不會那么卡,當然你哪一個N年前的手機給我,那也的卡,只不過需要適中的范圍里面
     *
     * @param canvas
     * @param x0
     * @param y0
     * @param w0
     * @param x1
     * @param y1
     * @param w1
     * @param paint
     */
    private void drawLine(Canvas canvas, double x0, double y0, double w0, double x1, double y1, double w1, Paint paint) {
        //求兩個數字的平方根 x的平方+y的平方在開方記得X的平方+y的平方=1,這就是一個園
        double curDis = Math.hypot(x0 - x1, y0 - y1);
        int steps = 1;
        if (paint.getStrokeWidth() < 6) {
            steps = 1 + (int) (curDis / 2);
        } else if (paint.getStrokeWidth() > 60) {
            steps = 1 + (int) (curDis / 4);
        } else {
            steps = 1 + (int) (curDis / 3);
        }
        double deltaX = (x1 - x0) / steps;
        double deltaY = (y1 - y0) / steps;
        double deltaW = (w1 - w0) / steps;
        double x = x0;
        double y = y0;
        double w = w0;

        for (int i = 0; i < steps; i++) {
            //都是用于表示坐標系中的一塊矩形區域,并可以對其做一些簡單操作
            //精度不一樣。Rect是使用int類型作為數值,RectF是使用float類型作為數值。
            //            Rect rect = new Rect();
            RectF oval = new RectF();
            oval.set((float) (x - w / 4.0f), (float) (y - w / 2.0f), (float) (x + w / 4.0f), (float) (y + w / 2.0f));
            //            oval.set((float)(x+w/4.0f), (float)(y+w/4.0f), (float)(x-w/4.0f), (float)(y-w/4.0f));
            //最基本的實現,通過點控制線,繪制橢圓
            canvas.drawOval(oval, paint);
            x += deltaX;
            y += deltaY;
            w += deltaW;
        }
    }
}

最后說明幾點:

1、當一直處于onDown事件的時候,就是按著屏幕不動的時候,最后繪制的關鍵方法也一直在走,消耗內存,需要優化

2、繪制水彩筆的時候:這里由于在設置筆的透明度,會導致整個線,或者說整個畫布的的顏透明度隨著整個筆的透明度而變化, 所以在這里考慮是不是說,繪制毛筆的時候,每次都給它new 一個paint ,但是這里我還沒有找到更好的辦法,也需要優化

3、關于擴展性的問題,目前這個Demo僅提供了三種的功能,后續會加上返回上一步,設置筆的顏色和寬度,在手指抬起來自動生成一張bitmap,設置畫布等功能

Git地址,歡迎點贊,歡迎提問,謝謝:https://github.com/Shimingli/WritingPen

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容