Canvas常用方法解析第一篇

1. 圖像扭曲

Canvas中提供了一個drawBitmapMesh方法,通過該方法可以實現位圖的扭曲效果,下面來分析一下這個方法:

方法簽名如下:
public void drawBitmapMesh(@NonNull Bitmap bitmap, int meshWidth, int meshHeight,
            @NonNull float[] verts, int vertOffset, @Nullable int[] colors, int colorOffset,
            @Nullable Paint paint)
1> 該方法將bitmap橫向、縱向分別均勻切割成meshWidth、meshHeight份,這樣的化bitmap就被切割成網格狀,
如圖1所示;
2> 網格交叉點坐標有(meshWidth + 1)*(meshHeight + 1)個,verts數組就是用來保存網格交叉點的坐標,
vertOffset表示verts數組從第幾個元素開始保存網格交叉點的坐標,因此verts數組的長度至少為
(meshWidth + 1)*(meshHeight + 1)*2+ vertOffset;
3> colors用于保存為網格的交叉點指定的顏色,該顏色會和位圖中對應的顏色進行multiplied
(multiplied可以參考圖2),colorOffset表示colors數組從第幾個元素開始保存為網格的交叉點指定的顏色,
因此colors數組的長度至少為(meshWidth + 1)*(meshHeight + 1)+ colorOffset,colors可以為null;
4> paint表示用于繪制bitmap的畫筆,可以為null。

注意:該方法在API的級別大于等于18時才支持硬件加速

圖1

圖2

實現水波紋效果:
首先通過俯視的視角看一下水波紋效果:

水波紋俯視圖

上圖中繪制了一個波長的水波紋,波峰到波源的距離是波的半徑,波長為相鄰波谷/波峰之間的距離;為了讓圖片有波動的感覺,在水波紋的范圍內(上圖中的藍色區域),以波峰為分界線,內側的點向內偏移,外側的點向外偏移,再通過水平視角看一下水波紋的效果:
水平視角的水波紋

由上圖可知離波峰越近的頂點,偏移的距離會越大,反之越小;那么可以利用余弦函數來計算偏移的距離。

先來秀一下最后實現的效果:



實現步驟:
1> 自定義繼承自View的RippleView,參數初始化:

// 實現水波紋效果的位圖
private Bitmap meshBitmap = null;
// 網格的行數
private static final int MESH_WIGHT = 20;
// 網格的列數
private static final int MESH_HEIGHT = 20;
// 網格的格數
private static final int MESH_COUNT = (MESH_WIGHT + 1) * (MESH_HEIGHT + 1);

// 保存網格交叉點的原始坐標
private final float[] originVerts = new float[MESH_COUNT * 2];
// 保存網格交叉點變換后的坐標
private final float[] targetVerts = new float[MESH_COUNT * 2];

//水波寬度的一半
private float rippleWidth = 100f;
//水波擴散速度
private float rippleSpeed = 15f;
//水波半徑
private float rippleRadius;
//水波動畫是否執行中
private boolean isRippling = false;

public RippleView(Context context) {
    super(context);
    initData(context, null);
}

public RippleView(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
}

public RippleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initData(context, attrs);
}

private void initData(Context context, @Nullable AttributeSet attrs) {
    TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RippleView);
    Drawable drawable = ta.getDrawable(R.styleable.RippleView_ripple_view_image);
    if (null == drawable || !(drawable instanceof BitmapDrawable)) {
        throw new IllegalArgumentException("ripple_view_image only support images!");
    }
    meshBitmap = ((BitmapDrawable) drawable).getBitmap();
    ta.recycle();
    int width = meshBitmap.getWidth();
    int height = meshBitmap.getHeight();
    int index = 0;
    for (int row = 0; row <= MESH_HEIGHT; row++) {
        float y = height * row / MESH_HEIGHT;
        for (int col = 0; col <= MESH_WIGHT; col++) {
            float x = width * col / MESH_WIGHT;
            originVerts[index * 2] = targetVerts[index * 2] = x;
            originVerts[index * 2 + 1] = targetVerts[index * 2 + 1] = y;
            index++;
        }
    }
}

上面的注釋應該很清晰了,我就不再贅敘了。

2> 當手指觸碰位圖時,onTouchEvent方法就會被回調:

@Override
public boolean onTouchEvent(MotionEvent event) {
    final int action = MotionEventCompat.getActionMasked(event);
    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            showRipple(event.getX(), event.getY());
            break;
        }
    }
    return true;
}

private void showRipple(final float touchPointX, final float touchPointY) {
    if (isRippling) {
        return;
    }
    //根據水波擴散速度和位圖對角線距離計算出刷新次數,確保水波紋完全消失
    int viewLength = (int) getLength(meshBitmap.getWidth(), meshBitmap.getHeight());
    final int count = (int) ((viewLength + rippleWidth * 2) / rippleSpeed);
    final ValueAnimator valueAnimator = ValueAnimator.ofInt(1, count);
    valueAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationStart(Animator animation) {
            super.onAnimationStart(animation);
            isRippling = true;
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            isRippling = false;
            valueAnimator.removeAllUpdateListeners();
            valueAnimator.removeAllListeners();
        }
    });
    valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            int animatorValue = (int) animation.getAnimatedValue();
            rippleRadius = animatorValue * rippleSpeed;
            warp(touchPointX, touchPointY);
        }
    });
    valueAnimator.setDuration(count * 10);
    valueAnimator.start();
}

/**
 * 根據寬高,獲取對角線距離
 *
 * @param width  寬
 * @param height 高
 * @return 距離
 */
private float getLength(float width, float height) {
    return (float) Math.sqrt(width * width + height * height);
}

從上面的代碼可知當點擊位圖時,showRipple方法會被調用,該方法主要做了兩件事情:
<1> 位了實現水波紋逐漸擴散直到完全消失的效果,首先就要計算出刷新次數:



如果在極限情況下(位圖的四個頂點作為點擊點)水波紋也能完全消失,那就達到了理想的效果,上圖就是模擬極限情況下繪制的效果圖,中間最小的的圓形區域就是水波紋最開始的狀態,最外側的圓環區域就是水波紋結束的狀態,因此刷新的次數為:

// 獲取位圖的對角線長度
int viewLength = (int) getLength(meshBitmap.getWidth(), meshBitmap.getHeight());
// 對角線長度加上一個波長就是水波紋的移動距離,然后除以波速就等到了刷新的次數
final int count = (int) ((viewLength + rippleWidth * 2) / rippleSpeed);

<2> 通過動畫實現刷新,warp方法被調用。

3> warp方法源碼如下:

/**
 * 計算圖片變換后的網格交叉點的坐標
 *
 * @param touchPointX 觸摸點 x 坐標
 * @param touchPointY 觸摸點 y 坐標
 */
private void warp(float touchPointX, float touchPointY) {
    for (int i = 0; i < MESH_COUNT * 2; i += 2) {
        float originVertX = originVerts[i];
        float originVertY = originVerts[i + 1];
        float length = getLength(originVertX - touchPointX, originVertY - touchPointY);
        // 判斷網格交叉點是否在水波紋區域
        if (length > rippleRadius - rippleWidth && length < rippleRadius + rippleWidth) {
            PointF point = getRipplePoint(touchPointX, touchPointY, originVertX, originVertY);
            targetVerts[i] = point.x;
            targetVerts[i + 1] = point.y;
        } else {
            targetVerts[i] = originVerts[i];
            targetVerts[i + 1] = originVerts[i + 1];
        }
    }
    invalidate();
}

warp方法遍歷所有的網格交叉點,然后判斷網格交叉點是否在水波紋區域,如果在水波紋區域,就會通過getRipplePoint方法獲取到網格交叉點偏移后的坐標,否則不做任何處理。getRipplePoint方法的源碼如下:

/**
 * 獲取網格交叉點的偏移坐標
 *
 * @param touchPointX 觸摸點 x 坐標
 * @param touchPointY 觸摸點 y 坐標
 * @param originVertX 待偏移頂點的原 x 坐標
 * @param originVertY 待偏移頂點的原 y 坐標
 * @return 偏移后坐標
 */
private PointF getRipplePoint(float touchPointX, float touchPointY, float originVertX, float originVertY) {
    float length = getLength(originVertX - touchPointX, originVertY - touchPointY);
    //偏移點與觸摸點間的角度
    float angle = (float) Math.atan(Math.abs((originVertY - touchPointY) / (originVertX - touchPointX)));
    //通過余弦函數計算直線偏移距離,這樣的話水波紋會更加生動
    float rate = (length - rippleRadius) / rippleWidth;
    float offset = (float) Math.cos(rate) * 10f;
    //計算在橫向和縱向上的偏移距離
    float offsetX = offset * (float) Math.cos(angle);
    float offsetY = offset * (float) Math.sin(angle);
    //偏移后的坐標
    float targetX;
    float targetY;
    if (length < rippleRadius + rippleWidth && length > rippleRadius) {
        //波峰外的偏移坐標
        if (originVertX > touchPointX) {
            targetX = originVertX + offsetX;
        } else {
            targetX = originVertX - offsetX;
        }
        if (originVertY > touchPointY) {
            targetY = originVertY + offsetY;
        } else {
            targetY = originVertY - offsetY;
        }
    } else {
        //波峰內的偏移坐標
        if (originVertX > touchPointX) {
            targetX = originVertX - offsetX;
        } else {
            targetX = originVertX + offsetX;
        }
        if (originVertY > touchPointY) {
            targetY = originVertY - offsetY;
        } else {
            targetY = originVertY + offsetY;
        }
    }
    return new PointF(targetX, targetY);
}

getRipplePoint方法主要做了兩件事情:
<1> 通過觸碰點的坐標和網格交叉點的坐標計算出網格交叉點在橫向和縱向的偏移距離,下圖是處于波峰外側的網格交叉點計算偏移距離的過程圖:



結合上圖,代碼中計算網格交叉點的在橫向和縱向的偏移距離應該就很容易理解了。
<2> 得到了網格交叉點在橫行和縱向的偏移距離后,然后根據在波峰內側還是外側來計算偏移后的坐標。

warp方法得到getRipplePoint方法返回的偏移后的坐標,保存到targetVerts數組中,接下來就是刷新界面,onDraw方法被調用:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawBitmapMesh(meshBitmap, MESH_WIGHT, MESH_HEIGHT, targetVerts, 0, null, 0, null);
}

warp會被動畫執行很多次,直到水波紋完全消失,從而實現了波動的效果。

2. 繪制文本

我們在自定義View中有的時候會想自己繪制文字,自己繪制文字的時候,我們通常希望把文字精確定位,文字居中(水平、垂直)是普遍的需求,所以這里就以文字居中為例。Android是通過Canvas中的drawText方法進行文字繪制的,方法使用說明如下:

public void drawText(@NonNull String text, int start, int end, float x, float y, @NonNull Paint paint) {
text:要繪制的字符串
start:第一個要繪制字符的下標值
end:最后一個要繪制字符的下標值
x默認是字符串的左邊在屏幕的位置,如果設置了paint.setTextAlign(Paint.Align.CENTER);那就是字符串的中心對應的x坐標,y是指定字符串baseline在屏幕上的位置。

Canvas繪制文本時,通過Paint對象獲取FontMetrics對象,然后利用FontMetrics對象計算baseline在屏幕上的位置。 它的思路和java.awt.FontMetrics的基本相同。 FontMetrics對象它以四個基本坐標為基準,如下圖所示:

基準線視圖
FontMetrics.top       該距離是從所繪字符的baseline之上至可繪制區域的最高點。
FontMetrics.ascent    該距離是從所繪字符的baseline之上至該字符所繪制的最高點。這個距離是系統推薦。
FontMetrics.descent   該距離是從所繪字符的baseline之下至該字符所繪制的最低點。這個距離是系統推薦的。
FontMetrics.bottom    該距離是從所繪字符的baseline之下至可繪制區域的最低點。

由上圖可以知道字符串的繪制區域在FontMetrics.ascent和FontMetrics.descent之間,因此讓字符串垂直居中顯示就相當于讓字符串的繪制區域垂直居中顯示;由于drawText方法中y參數所需要的值就是圖中的紅線(baseline)對應的y值,因此只要計算出字符串的繪制區域垂直居中顯示時紅線(baseline)對應的y值即可,計算過程如下:

假設我們所求的baseline的值為baseY;
text的descent距離:
①descentY = baseY + fontMetrics.descent; 
text的字體高度:
②fontHeight = fontMetrics.descent- fontMetrics.ascent 
因為我們要讓text垂直居中,所以此時text的bottom距離應該為:
③descentY=1/2 * height + 1/2 * fontHeight
所以由上述①②③公式就可以推得:
baseY = 1/2 * height - 1/2 * (fontMetrics.ascent + fontMetrics.descent) 
此時求得baseline的值,即cavans.drawText()里的y的坐標。

獲取fontMetrics的方法如下:

mFontMetricsInt = mPaint.getFontMetricsInt();
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,106評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,441評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,211評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,736評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,475評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,834評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,829評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,009評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,559評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,306評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,516評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,038評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,728評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,132評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,443評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,249評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,484評論 2 379

推薦閱讀更多精彩內容