1. 程式人生 > >用Canvas和屬性動畫造一隻萌蠢的“小鬼”

用Canvas和屬性動畫造一隻萌蠢的“小鬼”

最近沒事的時候想自己寫一個支援下拉重新整理,上拉載入的自定義View。寫著寫著,就覺得最常見的“一個圈轉啊轉”的進度條太普通了。
於是,就想看看有沒有更有趣的一點的載入效果。在GitHub上以”android loading”為關鍵字一搜索,就發現有作者開源了這麼一個庫:

這裡寫圖片描述

這裡寫圖片描述

那麼,開源的好處就來了,立刻開啟原始碼瞧一瞧別人是怎麼實現的吧。一看發現沒有藉助任何圖片,而就是通過canvas配合屬性動畫完成的整個效果。
按理說別人造好的輪子,我們直接拿來用就好了。但既然感興趣,為什麼不學習一下別人的思路,自己也來實現一個,從而得到提高呢?
所以,綜合一想,自己也重新來畫一畫這個萌蠢萌蠢的小鬼吧。並通過此文來總結一下整個自定義view的思路和收穫。
(P.S:會借鑑原作者的思路,但具體實現細節會有不同,但思路當然才是最重要的,具體實現選擇自己喜歡的就好)

自定義View的建立

其實說起繪畫,就想起了小時候流行的一個口訣,是畫“丁老頭”的,印象中有“一個丁老頭兒,借我倆煤球兒,我說三天還,他說四天還..”之類的。
其實就是這樣的,如果猛的一下讓我們畫個“老頭兒”出來,我們可能會有點懵逼。但按照口訣那樣一部分一部分的畫,似乎就變得容易多了。

所以,我們也可以模仿這個思路來畫這個小鬼。我們簡單分析一下,可以發現這個小鬼的構成其實就是:頭 + 眼睛 + 身體 + 影子
那麼,還等什麼呢?趕緊按照這個思路“開畫”吧!首先,我們當然是新建一個類,並讓其繼承View,而名字的話就叫GhostView好了。

onMeasure()

在正式開始“作畫”之前,我們肯定是做好相關的準備工作。比如,先確定好要用多大尺寸的“畫紙”。哈哈,其實也就是完成View的measure工作。
我們知道自定義View的時候,如果使用預設的onMeasure()方法:WRAP_CONTENT也會被當做MATCH_PARENT來測量,所以其實要做的也很簡單:

    // View寬高
    private int mWidth, mHeight;
    // 預設寬高(WRAP_CONTENT)
    private int mDefaultWidth = dip2px(120);
    private int mDefaultHeight = dip2px(180
); @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec)); } private int measureWidth(int widthMeasureSpec) { int specMode = MeasureSpec.getMode(widthMeasureSpec); int specSize = MeasureSpec.getSize(widthMeasureSpec); if (specMode == MeasureSpec.EXACTLY) { mWidth = specSize; } else if (specMode == MeasureSpec.AT_MOST) { mWidth = Math.min(mDefaultWidth, specSize); } return mWidth; } private int measureHeight(int heightMeasureSpec) { int specMode = MeasureSpec.getMode(heightMeasureSpec); int specSize = MeasureSpec.getSize(heightMeasureSpec); if (specMode == MeasureSpec.EXACTLY) { mHeight = specSize; } else if (specMode == MeasureSpec.AT_MOST) { mHeight = Math.min(mDefaultHeight, specSize); } return mHeight; }

非常簡單,思路就是:

  • 當View的寬高指定為MATCH_PARENT或者明確的值的時候,就使用實際的值。
  • 當View的寬高指定為WRAP_CONTENT時,寬度為預設的120dp,高度則為180dp。

Paint,準備畫筆

顯然,想要畫東西,當然我們還需要畫筆。可以從之前的效果圖裡看到,“小鬼”的整個形象需要三種顏色元素,分別是:
白色的身體、黑色的眼睛、以及灰灰的影子。所以,對應來說,我們也需要準備三支不同顏色的畫筆:

    // 畫筆
    Paint mBodyPaint, mEyesPaint, mShadowPaint;
    private void initPaint() {
        mBodyPaint = new Paint();
        mBodyPaint.setAntiAlias(true);
        mBodyPaint.setStyle(Paint.Style.FILL);
        mBodyPaint.setColor(Color.WHITE);

        mEyesPaint = new Paint();
        mEyesPaint.setAntiAlias(true);
        mEyesPaint.setStyle(Paint.Style.FILL);
        mEyesPaint.setColor(Color.BLACK);

        mShadowPaint = new Paint();
        mShadowPaint.setAntiAlias(true);
        mShadowPaint.setStyle(Paint.Style.FILL);
        mShadowPaint.setColor(Color.argb(60, 0, 0, 0));
    }

從“頭”開始,畫個圓腦袋

現在我們的準備工作都做好了,自然就可以正式開始畫這個“小鬼”了。我們先從最容易畫的入手,搞個圓圓的腦袋出來:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawHead(canvas);
    }

    // 頭部的半徑
    private int mHeadRadius;
    // 圓心(頭部)的X座標
    private int mHeadCentreX;
    // 圓心(頭部)的Y座標
    private int mHeadCentreY;
    // 頭部最左側的座標
    private int mHeadLeftX;
    // 頭部最右側的座標
    private int mHeadRightX;
    // 距離View頂部的內邊距
    private int mPaddingTop = dip2px(20);

    private void drawHead(Canvas canvas) {
        mHeadRadius = mWidth / 3;
        mHeadCentreX = mWidth / 2;
        mHeadCentreY = mWidth / 3 + mPaddingTop;
        mHeadLeftX = mHeadCentreX - mHeadRadius;
        mHeadRightX = mHeadCentreX + mHeadRadius;
        canvas.drawCircle(mHeadCentreX, mHeadCentreY, mHeadRadius, mBodyPaint);
    }

程式碼同樣很簡單,事實上現在我們就可以使用這個自定義View,來看一看目前為止的效果了:

<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorPrimary">

    <me.rawn_hwang.ghostdrawer.Ghost
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:background="@color/colorAccent" />

</RelativeLayout>

這裡寫圖片描述

。。。。。。。。好吧,目前為止我們還看不到任何“萌蠢小鬼”的跡象。沒關係,一步一步的來。

誰說“鬼”就沒影子

有了小鬼的頭之後,我們接著做什麼呢?正常來說我們應該想著接著畫身體。但是從之前的效果圖我們可以看到,小鬼的底部是有一個影子的。
所以,個人選擇先畫這個影子。因為:影子位於View的底部,先完成影子的繪畫,之後更方面我們確定小鬼身體的高度和位置。

其實所謂的影子也非常的簡單,就是一個灰濛濛的“橢圓形”而已:

    // 影子所佔區域
    private RectF mRectShadow;
    // 小鬼身體和影子之間的舉例
    private int paddingShadow;

    private void drawShadow(Canvas canvas) {
        paddingShadow = mHeight / 10;
        mRectShadow = new RectF();
        mRectShadow.top = mHeight * 8 / 10;
        mRectShadow.bottom = mHeight * 9 / 10;
        mRectShadow.left = mWidth / 4;
        mRectShadow.right = mWidth * 3 / 4;
        canvas.drawArc(mRectShadow, 0, 360, false, mShadowPaint);
    }

這個時候,我們再來看一看效果變成了什麼樣子:

這裡寫圖片描述

重頭戲,加上身體

現在,我們就來到了最關鍵的部分了:為小鬼加上身體。其實總的來說,小鬼的身體就是在頭部大約半圓的位置,分別畫上兩條帶有弧度的延長線。
但是,怎麼才能讓小鬼身體的這兩條線與頭部比較完美的融合呢?原作者在這裡使用了一些正弦、餘弦的公式來計算圓的弧度,從而完成了需要。
然而,悔不及當初沒有好好唸書啊,患上了暈“數學公式”的病。所以我機智的選擇用另一種方法,雖然沒那麼高大上,但是比較簡單。就像下面這樣:

    private Path mPath = new Path();
    // 小鬼身體胖過頭部的寬度
    private int mGhostBodyWSpace;

    private void drawBody(Canvas canvas) {
        mGhostBodyWSpace = mHeadRadius * 2 / 15;
        // 先畫右邊的身體
        mPath.moveTo(mHeadLeftX, mHeadCentreY);
        mPath.lineTo(mHeadRightX, mHeadCentreY);
        mPath.quadTo(mHeadRightX + mGhostBodyWSpace, mRectShadow.top - paddingShadow,
                     mHeadRightX - mGhostBodyWSpace, mRectShadow.top - paddingShadow);

        canvas.drawPath(mPath,mBodyPaint);
    }

這裡寫圖片描述

上圖中左邊的部分就是我們目前為止得到的效果;而右邊就是通過把畫筆設定為stroke來解釋這樣做的原理,實際上就是:先通過lineTo在小鬼頭部的中間畫一條直徑,這個時候path的LastPoint就到了最右邊的這個點,然後我們從這個點在右邊向下畫一條二階貝塞爾曲線,就有了小鬼右邊身體的輪廓了。

那麼接著我們該做什麼呢?回憶一下,我們發現小鬼的身體下方是有“波紋”的,就想裙子的褶皺一樣,所以我們現在就給添上裙子。
其實原理仍然很簡單,這個時候path的LastPoint也已經移動到了小鬼右邊身體的下面,我們從這裡開始向左不斷畫多個貝塞爾曲線形成裙褶就行了:

    // 單個裙褶的寬高
    private int mSkirtWidth, mSkirtHeight;
    // 裙褶的個數
    private int mSkirtCount = 7;

    private void drawBody(Canvas canvas) {
        mGhostBodyWSpace = mHeadRadius * 2 / 15;
        mSkirtWidth = (mHeadRadius * 2 - mGhostBodyWSpace * 2) / mSkirtCount;
        mSkirtHeight = mHeight / 16;

        // ......

        // 從右向左畫裙褶
        for (int i = 1; i <= mSkirtCount; i++) {
            if (i % 2 != 0) {
                mPath.quadTo(mHeadRightX - mGhostBodyWSpace - mSkirtWidth * i + (mSkirtWidth / 2), mRectShadow.top - paddingShadow - mSkirtHeight,
                        mHeadRightX - mGhostBodyWSpace - (mSkirtWidth * i), mRectShadow.top - paddingShadow);
            } else {
                mPath.quadTo(mHeadRightX - mGhostBodyWSpace - mSkirtWidth * i + (mSkirtWidth / 2), mRectShadow.top - paddingShadow + mSkirtHeight,
                        mHeadRightX - mGhostBodyWSpace - (mSkirtWidth * i), mRectShadow.top - paddingShadow);
            }
        }

        canvas.drawPath(mPath,mBodyPaint);
    }

這裡寫圖片描述

可以看到到了現在,基本就能看見整個小鬼的輪廓了,但我們注意到小鬼左邊似乎有點僵硬。沒關係,我們也給他加上一點對應的弧度就行了:

        mPath.quadTo(mHeadLeftX - mGhostBodyWSpace, mRectShadow.top - paddingShadow, mHeadLeftX, mHeadCentreY);

這裡寫圖片描述

畫“鬼”點睛

到了現在,我們的繪圖工作其實基本就已經完成了。但眼睛是心靈的窗戶,少了眼睛,這個小鬼看上去有點四不像的感覺。趕緊加上眼睛吧!

同樣的,眼睛的繪製其實也非常簡單,就在先要的位置,畫上兩個黑色的小圓就可以了:

    private void drawEyes(Canvas canvas) {
        canvas.drawCircle(mHeadCentreX , mHeadCentreY, mHeadRadius / 6, mEyesPaint);
        canvas.drawCircle(mHeadCentreX + mHeadRadius / 2, mHeadCentreY, mHeadRadius / 6, mEyesPaint);
    }

現在我們所有的繪製工作就完成了,把之前粉紅色的背景顏色去掉,再看看效果,是不是有點呆萌的趕腳了呢?

這裡寫圖片描述

讓小鬼動起來

現在小鬼我們已經畫完了,剩下的工作自然就是讓它動起來,別死氣沉沉的。而我們已經知道了,這個工作就是通過屬性動畫來完成的。

那麼,我們可以新增一個最簡單的位移動畫,比如說這樣做:

    private void startAnim(){
        ObjectAnimator animator = ObjectAnimator.ofFloat(this,"translationX",0,500);
        animator.setRepeatMode(ObjectAnimator.RESTART);
        animator.setRepeatCount(ObjectAnimator.INFINITE);
        animator.setDuration(5000);
        animator.start();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // ......
        startAnim();
    }

這裡寫圖片描述

可以看到這樣“小鬼”就已經動起來了,不過現在它肯定沒有那麼萌了。因為它的行進路徑和恐怖片裡那些白衣幽靈看上去一樣一樣的。
不過這裡主要是表達個意思嘛,要實現作者原本的那個動畫效果實際上也不難,我們分析一下可以發現它主要有幾個動作:
就是小鬼在行進的同時還會上下跳動,並且底部的影子會隨著小鬼跳起和落下而改變大小,那麼我們就可以藉助ValueAnimator來實現。
簡單來說,要做的工作就是之前描繪小鬼時的相關屬性(例如小鬼的頭部的圓心座標,影子的rect的寬度等)不要寫死,而是與某個值產生關聯。
然後我們用ValueAnimator來監聽和不斷的改變這個值,然後讓view不斷重繪,就可以得到響應的一些動畫效果了。