Android Canvas 繪製小黃人

學習往往是枯燥的,如果能用一個有趣 Demo 來學習和練習技術,那對知識的掌握就會更牢固。我在學習 Canvas 繪製 API 的時候就是這樣做的。

效果圖

 

我覺得這個繪製小黃人的自定義 View 就很有意思,也爲我後來工作中的自定義 View 實現打下了良好的基礎。雖然這是 4 年半以前寫的文章,但是大部分關注我們的同學應該沒看過,今天咱們一起來拷古翻新一下代碼(程序員的事,怎麼能叫炒冷飯呢,這明明是溫故而知新)。以後有機會還會分享項目實用自定義 View,敬請關注。

實現步驟

其實很簡單

  1. 首先找到一張小黃人的圖

  2. 然後調用 canvas.drawBitmap() 後畫到畫布上 好吧,一點都不好笑  - -。

準備工作

自定義MinionView extends View,定義以下成員變量,備用(可以先不看,後面的代碼看到莫名其妙出來的變量再上來看下)

private float bodyWidth;
private float bodyHeight;
private static final float BODY_SCALE = 0.6f; // 身體主幹佔整個view的比重
private static final float BODY_WIDTH_HEIGHT_SCALE = 0.6f; // 身體的比例設定爲 w:h = 3:5

private float mStrokeWidth = 4; // 描邊寬度
private float offset; // 計算時,部分需要 考慮描邊偏移
private float radius; // 身體上下半圓的半徑
private int colorClothes = Color.rgb(32, 116, 160); // 衣服的顏色
private int colorBody = Color.rgb(249, 217, 70); // 身體的顏色
private int colorStroke = Color.BLACK;
private RectF bodyRect = new RectF();
private float handsHeight;// 計算出吊帶的高度時,可以用來做手的高度
private float footHeight; // 腳的高度,用來畫腳部陰影時用

初始化參數

重寫 onSizeChanged 方法,尺寸變化時初始化一下繪製的參數(會經常看到一些奇怪的數字,用做比例換算,別問我怎麼來的,目測 + 一點點微調得來的- -。)

private void initParams() {
    bodyWidth = Math.min(getWidth(), getHeight() * BODY_WIDTH_HEIGHT_SCALE) * BODY_SCALE;
    bodyHeight = Math.min(getWidth(), getHeight() * BODY_WIDTH_HEIGHT_SCALE) / BODY_WIDTH_HEIGHT_SCALE * BODY_SCALE;

    mStrokeWidth = Math.max(bodyWidth / 50, mStrokeWidth);
    offset = mStrokeWidth / 2;

    bodyRect.left = (getWidth() - bodyWidth) / 2;
    bodyRect.top = (getHeight() - bodyHeight) / 2;
    bodyRect.right = bodyRect.left + bodyWidth;
    bodyRect.bottom = bodyRect.top + bodyHeight;

    radius = bodyWidth / 2;
    footHeight = radius * 0.4333f; 

    handsHeight =  (getHeight() + bodyHeight) / 2   + offset - radius * 1.65f;
}

繪製參數好了,接下來就是一步步繪製幾何圖形了

畫身體

顯然身體是一個矩形加上,上下半圓,這邊只要用一個圓角矩形,然後圓角的弧度半徑用身體寬度的一半就可以達到這個效果了。把身體的矩形外存起來,後面經常要用到其相對位置進行對其它部位的定位,代碼如下:

protected void onDraw(Canvas canvas) {
    ...
    drawBody(canvas);       // 身體
    drawBodyStroke(canvas); // 最後畫身體的描邊,可以摭住一些過渡的棱角
}

private void drawBody(Canvas canvas) {
    mPaint.setColor(colorBody);
    mPaint.setStyle(Paint.Style.FILL);

    canvas.drawRoundRect(bodyRect, radius, radius, mPaint);
}

private void drawBodyStroke(Canvas canvas) {
    mPaint.setColor(colorStroke);
    mPaint.setStrokeWidth(mStrokeWidth);
    mPaint.setStyle(Paint.Style.STROKE);
    canvas.drawRoundRect(bodyRect, radius, radius, mPaint);
}

畫衣服

這是穿上褲子的樣子

  • 首先畫 底下的半圓

rect.left = (getWidth() - bodyWidth) / 2 + offset;
rect.top = (getHeight() + bodyHeight) / 2 - radius * 2 + offset;
rect.right = rect.left + bodyWidth - offset * 2;
rect.bottom = rect.top + radius * 2 - offset * 2;

mPaint.setColor(colorClothes);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(mStrokeWidth);
canvas.drawArc(rect, 0, 180, true, mPaint);
  • 再畫半圓上方的矩形, w 表示矩形離左邊身體的距離,h 矩形的高
int h = (int) (radius * 0.5);
int w = (int) (radius * 0.3);

rect.left += w;
rect.top = rect.top + radius - h;
rect.right -= w;
rect.bottom = rect.top + h;

canvas.drawRect(rect, mPaint);
  • 上面的畫完之後,要在衣服上面描一層黑色的邊,用canvas.drawLines把線一條條畫出來吧,這邊要同時考慮畫筆的描邊寬度,否則會出現連接點有鋸齒的感覺。( 2020 注:這是當時最直接的想法,現在來看用 Path 來繪製,每個點用 rLineTo 去連接,代碼會簡單得多。)

mPaint.setColor(colorStroke);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(mStrokeWidth);
float[] pts = new float[20];// 5 條線

pts[0] = rect.left - w;
pts[1] = rect.top + h;
pts[2] = pts[0] + w;
pts[3] = pts[1];

pts[4] = pts[2];
pts[5] = pts[3] + offset;
pts[6] = pts[4];
pts[7] = pts[3] - h;

pts[8] = pts[6] - offset;
pts[9] = pts[7];
pts[10] = pts[8] + (radius - w) * 2;
pts[11] = pts[9];

pts[12] = pts[10];
pts[13] = pts[11] - offset;
pts[14] = pts[12];
pts[15] = pts[13] + h;

pts[16] = pts[14] - offset;
pts[17] = pts[15];
pts[18] = pts[16] + w;
pts[19] = pts[17];
canvas.drawLines(pts, mPaint);
  • 畫吊帶 就是一個直角梯形,把梯形的四個頂點計算出來,使用canvas.drawPath將其畫上去,然後鈕釦用一個實心的小圓表示

// 畫左吊帶
path.reset();
path.moveTo(rect.left - w - offset, handsHeight);
path.lineTo(rect.left + h / 4f, rect.top + h / 2f);
final float smallW = w / 2f * (float) Math.sin(Math.PI / 4);
path.lineTo(rect.left + h / 4f + smallW, rect.top + h / 2f - smallW);
final float smallW2 = w / (float) Math.sin(Math.PI / 4) / 2;
path.lineTo(rect.left - w - offset, handsHeight - smallW2);
canvas.drawPath(path, mPaint);

mPaint.setColor(colorStroke);
mPaint.setStrokeWidth(mStrokeWidth);
mPaint.setStyle(Paint.Style.STROKE);
canvas.drawPath(path, mPaint);
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
canvas.drawCircle(rect.left + h / 5f, rect.top + h / 4f, mStrokeWidth * 0.7f, mPaint);

// 畫右吊帶,代碼差不多省略了,座標對稱
  • 畫中間的口袋 是一個下面兩邊是圓角的圓角矩形,但是貌似不能直接畫這樣的圓角矩形,所以我就用土辦法,不就是一個多邊形嗎,用canvas.drawPath來畫,在圓角的地方添加圓弧過渡path.addArc

path.reset();
float radiusBigPocket = w / 2.0f;
path.moveTo(rect.left + 1.5f * w, rect.bottom - h / 4f);
path.lineTo(rect.right - 1.5f * w, rect.bottom - h / 4f);
path.lineTo(rect.right - 1.5f * w, rect.bottom + h / 4f);
path.addArc(rect.right - 1.5f * w - radiusBigPocket * 2, rect.bottom + h / 4f - radiusBigPocket,
        rect.right - 1.5f * w, rect.bottom + h / 4f + radiusBigPocket, 0, 90);
path.lineTo(rect.left + 1.5f * w + radiusBigPocket, rect.bottom + h / 4f + radiusBigPocket);

path.addArc(rect.left + 1.5f * w, rect.bottom + h / 4f - radiusBigPocket,
        rect.left + 1.5f * w + 2 * radiusBigPocket, rect.bottom + h / 4f + radiusBigPocket, 90, 90);
path.lineTo(rect.left + 1.5f * w, rect.bottom - h / 4f - offset);
canvas.drawPath(path, mPaint);    
  • 左右兩個小口袋也直接用一個小弧來解決掉

// 下邊一豎,分開褲子
canvas.drawLine(bodyRect.left + bodyWidth / 2, bodyRect.bottom - h * 0.8f, bodyRect.left + bodyWidth / 2, bodyRect.bottom, mPaint);
// 左邊的小口袋
float radiusSmallPocket = w * 1.2f;
canvas.drawArc(bodyRect.left - radiusSmallPocket, bodyRect.bottom - radius - radiusSmallPocket,
       bodyRect.left + radiusSmallPocket, bodyRect.bottom - radius + radiusSmallPocket, 80, -60, false, mPaint);
// 右邊小口袋
canvas.drawArc(bodyRect.right - radiusSmallPocket, bodyRect.bottom - radius - radiusSmallPocket,
        bodyRect.right + radiusSmallPocket, bodyRect.bottom - radius + radiusSmallPocket, 100, 60, false, mPaint);
  • 嗯,衣服畫完了。

protected void onDraw(Canvas canvas) {
    ...
    drawClothes(canvas);//衣服
}

private void drawClothes(Canvas canvas) {
    //就是上面那一堆代碼按順序合起來啦。。。。。
}

畫腳

腳這部分比較簡單,從身體的下方,一個豎直的矩形下來,再加上一個左邊圓角的圓角矩形,還是通過畫Path來實現。

private void drawFeet(Canvas canvas) {
    mPaint.setStrokeWidth(mStrokeWidth);
    mPaint.setColor(colorStroke);
    mPaint.setStyle(Paint.Style.FILL_AND_STROKE);

    float radiusFoot = radius / 3 * 0.4f;
    float leftFootStartX = bodyRect.left + radius - offset * 2;
    float leftFootStartY = bodyRect.bottom - offset;
    float footWidthA = radius * 0.5f;//腳寬度大-到半圓結束
    float footWidthB = footWidthA / 3;//腳寬度-比較細的部分

    // 左腳
    path.reset();
    path.moveTo(leftFootStartX, leftFootStartY);
    path.lineTo(leftFootStartX, leftFootStartY + footHeight);
    path.lineTo(leftFootStartX - footWidthA + radiusFoot, leftFootStartY + footHeight);

    rect.left = leftFootStartX - footWidthA;
    rect.top = leftFootStartY + footHeight - radiusFoot * 2;
    rect.right = rect.left + radiusFoot * 2;
    rect.bottom = rect.top + radiusFoot * 2;
    path.addArc(rect, 90, 180);
    path.lineTo(rect.left + radiusFoot + footWidthB, rect.top);
    path.lineTo(rect.left + radiusFoot + footWidthB, leftFootStartY);
    path.lineTo(leftFootStartX, leftFootStartY);
    canvas.drawPath(path, mPaint);

  // 右腳與左腳實現一致,座標對稱,代碼略
}

畫手

這裏是雙手放在後背的樣子

手我用的是一個等腰直角三角形來實現,斜邊就是吊帶到褲子,從直角頂點作高到斜邊,通過小直角三角形的直角邊相等就可以算出頂點的座標。這個時候還是有個圓角,剛開始我實現的時候是像上面那些通過path.addArc加上圓角,但是這邊計算好之後和原來的銜接一直有問題,在調了半天之後,偶然發現mPaint.setPathEffect(new CornerPathEffect(radiusHand));這個方法,可以使path的拐角用圓角來過渡,一下子就簡單到爆了,果然科學技術是第一生產力。

private void drawHands(Canvas canvas) {
    ...       
    // 左手
    path.moveTo(bodyRect.left, handsHeight);
    path.lineTo(bodyRect.left - hypotenuse / 2, handsHeight + hypotenuse / 2);
    path.lineTo(bodyRect.left +offset, bodyRect.bottom - radius +offset);
    path.lineTo(bodyRect.left, handsHeight);
    canvas.drawPath(path, mPaint);

    mPaint.setStrokeWidth(mStrokeWidth);
    mPaint.setStyle(Paint.Style.STROKE);
    mPaint.setColor(colorStroke);
    canvas.drawPath(path, mPaint);

    // 右手略 ...
    // 手臂內側拐點
    path.reset();
    mPaint.setStyle(Paint.Style.FILL);
    path.moveTo(bodyRect.left, handsHeight + hypotenuse / 2 - mStrokeWidth);
    path.lineTo(bodyRect.left - mStrokeWidth * 2, handsHeight + hypotenuse / 2 + mStrokeWidth * 2);
    path.lineTo(bodyRect.left, handsHeight + hypotenuse / 2 + mStrokeWidth);
    canvas.drawPath(path, mPaint);
    ...
 }

畫眼睛,嘴巴

三個字,圓圓圓

反正就是各種畫圓,或者弧形,嘴巴部分偷懶也就一條小弧一筆帶過了,哈哈

private void drawEyesMouth(Canvas canvas) {
    // 眼睛中心處於上半圓直徑 往上的高度偏移
    float eyesOffset = radius * 0.1f;
    mPaint.setStrokeWidth(mStrokeWidth * 5);

    // 計算眼鏡帶弧行的半徑 分兩段,以便眼睛中間有隔開的效果
    float radiusGlassesRibbon = (float) (radius / Math.sin(Math.PI / 20));
    rect.left = bodyRect.left + radius - radiusGlassesRibbon;
    rect.top = bodyRect.top + radius - (float) (radius / Math.tan(Math.PI / 20)) - radiusGlassesRibbon - eyesOffset;
    rect.right = rect.left + radiusGlassesRibbon * 2;
    rect.bottom = rect.top + radiusGlassesRibbon * 2;
    canvas.drawArc(rect, 81, 3, false, mPaint);
    canvas.drawArc(rect, 99, -3, false, mPaint);

    // 眼睛半徑
    float radiusEyes = radius / 3;
    mPaint.setColor(Color.WHITE);
    mPaint.setStrokeWidth(mStrokeWidth);
    mPaint.setStyle(Paint.Style.FILL);

    canvas.drawCircle(bodyRect.left + bodyWidth / 2 - radiusEyes - offset, bodyRect.top + radius - eyesOffset, radiusEyes, mPaint);
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 + radiusEyes + offset, bodyRect.top + radius - eyesOffset, radiusEyes, mPaint);

    mPaint.setColor(colorStroke);
    mPaint.setStyle(Paint.Style.STROKE);
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 - radiusEyes - offset, bodyRect.top + radius - eyesOffset, radiusEyes, mPaint);
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 + radiusEyes + offset, bodyRect.top + radius - eyesOffset, radiusEyes, mPaint);

    final float radiusEyeballBlack = radiusEyes / 3;
    mPaint.setStyle(Paint.Style.FILL);
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 - radiusEyes - offset, bodyRect.top + radius - eyesOffset, radiusEyeballBlack, mPaint);
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 + radiusEyes + offset, bodyRect.top + radius - eyesOffset, radiusEyeballBlack, mPaint);

    mPaint.setColor(Color.WHITE);
    final float radiusEyeballWhite = radiusEyeballBlack / 2;
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 - radiusEyes + radiusEyeballWhite - offset * 2,
            bodyRect.top + radius - radiusEyeballWhite + offset - eyesOffset,
            radiusEyeballWhite, mPaint);
    canvas.drawCircle(bodyRect.left + bodyWidth / 2 + radiusEyes + radiusEyeballWhite,
            bodyRect.top + radius - radiusEyeballWhite + offset - eyesOffset,
            radiusEyeballWhite, mPaint);

    // 畫嘴巴,因爲位置和眼睛有相對關係,所以寫在一塊
    mPaint.setColor(colorStroke);
    mPaint.setStyle(Paint.Style.STROKE);
    mPaint.setStrokeWidth(mStrokeWidth);
    float radiusMonth = radius;
    rect.left = bodyRect.left;
    rect.top = bodyRect.top - radiusMonth / 2.5f;
    rect.right = rect.left + radiusMonth * 2;
    rect.bottom = rect.top + radiusMonth * 2;
    canvas.drawArc(rect, 95, -20, false, mPaint);
}

腳下的陰影

這是最後一步了,直接畫一個非常扁的橢圓放在腳下面就可以了

不科學啊,長這麼胖,爲毛影子這麼瘦(別在意這些細節)

private void drawFeetShadow(Canvas canvas) {
    mPaint.setColor(getResources().getColor(android.R.color.darker_gray));
    canvas.drawOval(bodyRect.left + bodyWidth * 0.15f,
            bodyRect.bottom - offset + footHeight,
            bodyRect.right - bodyWidth * 0.15f,
            bodyRect.bottom - offset + footHeight + mStrokeWidth * 1.3f, mPaint);
}

重寫 onDraw 方法

按層級依次調用上述的各種方法,畫完收工。

@Override
protected void onDraw(Canvas canvas) {
    drawFeetShadow(canvas); // 腳下的陰影
    drawFeet(canvas);       // 腳
    drawHands(canvas);      // 手
    drawBody(canvas);       // 身體
    drawClothes(canvas);    // 衣服
    drawEyesMouth(canvas);  // 眼睛,嘴巴
    drawBodyStroke(canvas); // 最後畫身體的描邊,可以摭住一些過渡的棱角
}

少了點什麼?

畫完了,好像少了點什麼。。。。。對了,頭髮。好吧,我畫的是程序猿,哪來的頭髮 - -

至此,正常畫風的小黃人已經畫完了,但是吧,好不容易畫好,好像沒啥意思,腦洞大開一下吧。電影中的小黃人中病毒後是會變成紫色的,那我們用代碼畫,換個顏色還不是分分鐘,不但要紫色,還要各種顏色。

三行代碼搞定腦洞

public void randomBodyColor() {
    Random random = new Random();
    colorBody = Color.rgb(random.nextInt(255), random.nextInt(255), random.nextInt(255));
    invalidate();
}

然後效果就變成了這樣。

粉絲交流會:

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