Android 仿instagram和微博的頭像點擊加載動畫

github:https://github.com/qintong91/InsLoadingAnimation
前段時間發現instagram點擊用戶頭像的加載小視頻動畫,效果如下:

ins.gif

對,就是轉圈圈的這個,這么酷炫,我也要做一個!在整理代碼和總結時候,神奇的事情發生了,在我日常刷微博的時候點開微博客戶端時候突然發現:
weibo.gif

緣分啊,發現了微博Android客戶端也上線了類似動畫!等等,不是類似,這是特么是除了顏色和ins的一毛一樣啊!
既然這個動畫效果這么火,那還不趕快把我實現分享出來
如下就是我實現的效果:
demo

工程鏈接:https://github.com/qintong91/InsLoadingAnimation
(你如果覺得不錯就不要控制自己,點進去star一下~)
下文,分別為整理介紹,使用,具體實現與總結。

1.介紹

InsLoadingView繼承自ImageView,其對應的image顯示為圓形。InsLoadingView有三種狀態:LOADING/UNCLICKED/CLICKED,Loading時候輪廓有不斷循環的動畫,如上圖(下文分析源碼時候會詳細闡明其過程)。UNCLICKED時外側輪廓為靜態的彩色圈,CLICKED外層為靜態的灰色圈。此外,在其被點擊時還有控件收縮的動畫效果。注意:由于狀態是與應用中的情況相關的,所以狀態變化需要用戶手動去設置。
整體效果如下(感謝家里的喵主子~)


2.使用

如果你想在自己的項目中使用的話,可以按如下幾步進行:

Step 1

在build.gradle增加依賴:

dependencies {
  compile 'com.qintong:insLoadingAnimation:1.0.1'
}

Step 2

InsLoadingView繼承自ImageView, 所以最基本的,可以按照ImageView的用法使用InsLoadingView:

<com.qintong.library.InsLoadingView
    android:layout_centerInParent="true"
    android:id="@+id/loading_view"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@mipmap/pink"/>

Step 3

設置狀態:

您可以手動設置其狀態,來對應在您應用中的當前狀態。InsLoadingView的狀態有:
LOADING: 表示InsLoadingView被點擊之后正在加載內容(未加載完畢之前),該狀態下動畫正在執行。
UNCLICKED: 該InsLoadingView被點擊之前的狀態,此狀態下動畫停止。
CLICKED: 表示InsLoadingView被點擊和加載過,此狀態下動畫停止切圓圈的顏色為灰色。
默認的狀態是LOADING。

可以通過一下代碼設置狀態:
xml:

  app:status="loading" //or "clicked",or "clicked"

java:

  mInsLoadingView.setStatus(InsLoadingView.Status.LOADING); //Or InsLoadingView.Status.CLICKED, InsLoadingView.Status.UNCLICKED

設置顏色

設置start color和start color,InsLoadingView的圓圈會顯示兩個顏色間的過渡。
可以按如下代碼設置:

xml:

  app:start_color="#FFF700C2" //or your color
  app:end_color="#FFFFD900" //or your color

java:

  mInsLoadingView.setStartColor(Color.YELLOW); //or your color
  mInsLoadingView.setEndColor(Color.BLUE); //or your color

默認的start color和start color為#FFF700C2和#FFFFD900。

設置速度

通過設置環繞動畫的時間和整體旋轉的時間來改變速度:

xml:

  app:circle_duration="2000"
  app:rotate_duration="10000"

java:

  mInsLoadingView.setCircleDuration(2000);
  mInsLoadingView.setRotateDuration(10000);

默認的時間為2000ms和10000ms。

2.實現

完整的代碼請見https://github.com/qintong91/InsLoadingAnimation
下面就對代碼進行分析。
InsLoadingView繼承自ImageView,動畫效果主要通過重寫onDraw()函數重新繪制。所以可以先看onDraw()方法:

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.scale(mScale, mScale, centerX(), centerY());
        drawBitmap(canvas);
        Paint paint = getPaint(getColor(0), getColor(360), 360);
        switch (mStatus) {
            case LOADING:
                drawTrack(canvas, paint);
                break;
            case UNCLICKED:
                drawCircle(canvas, paint);
                break;
            case CLICKED:
                drawClickedircle(canvas);
                break;
        }
    }

drawBitmap()為實現顯示圓形圖片重新完成了繪制圖片的過程。之后根據當前status繪制圖片外的圈:status為LOADING時候繪制時是動畫,其他兩種情況繪制是靜態的圓圈。

(1) 動畫繪制:

LOADING時候的動畫是項目中最核心的部分。從動畫效果中可以看出,圓弧的兩端都在運動:運動較慢的一端其實反應了外圈的整體旋轉(連同顏色),較快一端的旋轉還有兩個過程:圓弧向外“伸展”一圈和向回“收縮”一圈的過程。
degress和cricleWidth是實時變化的,他們的值由ValueAnimator設置,這兩個值分別表示整個動畫整體旋轉的角度(也就是動畫中轉速較慢一端)和轉速較快的圓弧的動畫。兩個變量的單位都是度degress范圍為0-360,cricleWidth范圍為-360到360。cricleWidth圓弧向回“收縮”和向外“伸展”的過程,分別對應代碼中的a和b過程,對應的circleWidth范圍為-360—0度和0—360度。
在a過程中,cricleWidth + 360換算得到成正的adjustCricleWidth,adjustCricleWidth到360度繪制一個扇形圓弧,adjustCricleWidth到0度,依次向后每隔12度畫小的扇形圓弧,圓弧的寬度遞減。
b過程中:從0到cricleWidth:最前端繪制4個小扇形圓弧,其后到0度繪制一個長圓弧。從360度到cricleWidth,每間隔12度依次繪制小圓弧,其寬度遞減。

    private void drawTrack(Canvas canvas, Paint paint) {
        canvas.rotate(degress, centerX(), centerY());
        canvas.rotate(ARC_WIDTH, centerX(), centerY());
        RectF rectF = new RectF(getWidth() * (1 - circleDia), getWidth() * (1 - circleDia),
                getWidth() * circleDia, getHeight() * circleDia);
        if (DEBUG) {
            Log.d(TAG, "cricleWidth:" + cricleWidth);
        }
        if (cricleWidth < 0) {
            //a
            float startArg = cricleWidth + 360;
            canvas.drawArc(rectF, startArg, 360 - startArg, false, paint);
            float adjustCricleWidth = cricleWidth + 360;
            float width = 8;
            while (adjustCricleWidth > ARC_WIDTH) {
                width = width - arcChangeAngle;
                adjustCricleWidth = adjustCricleWidth - ARC_WIDTH;
                canvas.drawArc(rectF, adjustCricleWidth, width, false, paint);
            }
        } else {
            //b
            for (int i = 0; i <= 4; i++) {
                if (ARC_WIDTH * i > cricleWidth) {
                    break;
                }
                canvas.drawArc(rectF, cricleWidth - ARC_WIDTH * i, 8 + i, false, paint);
            }
            if (cricleWidth > ARC_WIDTH * 4) {
                canvas.drawArc(rectF, 0, cricleWidth - ARC_WIDTH * 4, false, paint);
            }
            float adjustCricleWidth = 360;
            float width = 8 * (360 - cricleWidth) / 360;
            if (DEBUG) {
                Log.d(TAG, "width:" + width);
            }
            while (width > 0 && adjustCricleWidth > ARC_WIDTH) {
                width = width - arcChangeAngle;
                adjustCricleWidth = adjustCricleWidth - ARC_WIDTH;
                canvas.drawArc(rectF, adjustCricleWidth, width, false, paint);
            }
        }
    }

(2) 點擊View收縮效果:

在onDraw()方法中有:

        canvas.scale(mScale, mScale, centerX(), centerY());

控制了View在點擊后的整體收縮效果,mScale參數由ValueAnimator和觸摸事件控制。在onTouchEvent()中我們要分析event,ACTION_DOWN時候按下mScale開始變小,從當前值向最向0.9變化(中間值由ValueAnimator生成),在ACTION_UP和ACTION_CANCEL時候手指抬起,mScale由當前值向1變化。
這里值得注意的是,在重寫onTouchEvent()時候,有兩點要注意:1.要保證super.onTouchEvent(event)被調用,否則該View的OnClickListener和OnLongClickListener將不會響應(具體可見事件傳遞機制,OnClickListener/OnLongClickListener層級最低)。2.在處理ACTION_DOWN時候要保證返回值為True,否則同次動作的ACTION_UP等事件將不會再響應,這也是事件傳遞機制的內容。為保證這兩點,此處代碼如下:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean result = false;
        if (DEBUG) {
            Log.d(TAG, "onTouchEvent: " + event.getAction());
        }
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                startDownAnim();
                result = true;
                break;
            }
            case MotionEvent.ACTION_UP: {
                startUpAnim();
                break;
            }
            case MotionEvent.ACTION_CANCEL: {
                startUpAnim();
                break;
            }
        }
        return super.onTouchEvent(event) || result;
    }

    private void startDownAnim() {
        mTouchAnim.setFloatValues(mScale, 0.9f);
        mTouchAnim.start();

    }

    private void startUpAnim() {
        mTouchAnim.setFloatValues(mScale, 1);
        mTouchAnim.start();
    }

(3) ValueAnimator:

該項目用到了三個ValueAnimator:分別控制前文的degress,cricleWidth以及mScale,繪制圓弧的過程中是減速的過程,所以用了減速插值器,其他兩個過程用的都是線性插值器。此外,還需要判斷當前是繪制圓弧向外伸展還是向內伸縮,所以用了個boolean值isFirstCircle進行判斷,在動畫Repeat時候對其值反轉。代碼如下:

    private void onCreateAnimators() {
        mRotateAnim = ValueAnimator.ofFloat(0, 180, 360);
        mRotateAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                degress = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        mRotateAnim.setInterpolator(new LinearInterpolator());
        mRotateAnim.setDuration(mRotateDuration);
        mRotateAnim.setRepeatCount(-1);
        mCircleAnim = ValueAnimator.ofFloat(0, 360);
        mCircleAnim.setInterpolator(new DecelerateInterpolator());
        mCircleAnim.setDuration(mCircleDuration);
        mCircleAnim.setRepeatCount(-1);
        mCircleAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                if (isFirstCircle) {
                    cricleWidth = (float) animation.getAnimatedValue();
                } else {
                    cricleWidth = (float) animation.getAnimatedValue() - 360;
                }
                postInvalidate();
            }
        });
        mCircleAnim.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {

            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {
                isFirstCircle = !isFirstCircle;
            }
        });
        mTouchAnim = new ValueAnimator();
        mTouchAnim.setInterpolator(new DecelerateInterpolator());
        mTouchAnim.setDuration(200);
        mTouchAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mScale = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        startAnim();
    }

(4) 繪制圓形圖片:

由于與ImageView不同,這里圖片要顯示成圓形,所以這里我們通過Drawble拿到Bitmap對象后,將其BitmapShader修剪成正方形,paint的shader設置為其BitmapShader,再用該paint畫圓:

    private void drawBitmap(Canvas canvas) {
        Paint bitmapPaint = new Paint();
        setBitmapShader(bitmapPaint);
        RectF rectF = new RectF(getWidth() * (1 - bitmapDia), getWidth() * (1 - bitmapDia),
                getWidth() * bitmapDia, getHeight() * bitmapDia);
        canvas.drawOval(rectF, bitmapPaint);
    }

    private void setBitmapShader(Paint paint) {
        Drawable drawable = getDrawable();
        Matrix matrix = new Matrix();
        if (null == drawable) {
            return;
        }
        Bitmap bitmap = drawableToBitmap(drawable);
        BitmapShader tshader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
        float scale = 1.0f;
        int bSize = Math.min(bitmap.getWidth(), bitmap.getHeight());
        scale = getWidth() * 1.0f / bSize;
        matrix.setScale(scale, scale);
        if (bitmap.getWidth() > bitmap.getHeight()) {
            matrix.postTranslate(-(bitmap.getWidth() * scale - getWidth()) / 2, 0);
        } else {
            matrix.postTranslate(0, -(bitmap.getHeight() * scale - getHeight()) / 2);
        }
        tshader.setLocalMatrix(matrix);
        paint.setShader(tshader);
    }

    private Bitmap drawableToBitmap(Drawable drawable) {
        if (drawable instanceof BitmapDrawable) {
            BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
            return bitmapDrawable.getBitmap();
        }
        int w = drawable.getIntrinsicWidth();
        int h = drawable.getIntrinsicHeight();
        Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, w, h);
        drawable.draw(canvas);
        return bitmap;
    }

(5) 顏色:

在onDraw()中,getPaint()得到了從mStartColor到mEndColor的過渡的顏色:

   Paint paint = getPaint(mStartColor, mEndColor, 360);

其中:

    private Paint getPaint(int startColor, int endColor, double arcWidth) {
        Paint paint = new Paint();
        Shader shader = new LinearGradient(0f, 0f, (float) (getWidth() * circleDia * (arcWidth - ARC_WIDTH * 4) / 360),
                getHeight() * strokeWidth, startColor, endColor, CLAMP);
        paint.setShader(shader);
        setPaintStroke(paint);
        return paint;
    }

(6) 重寫onMeasure():

因為該控件是圓形,所以還需要重寫onMeasure()方法,使其最后長和高一致,并針對MATCH_PARENT和WRAP_CONTENT以及指定具體寬高的情況下分別處理,注意WRAP_CONTENT下這里是指定了最大寬/高為300px,這與ImageView不同。代碼如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        final int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        final int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        if (DEBUG) {
            Log.d(TAG, "onMeasure widthMeasureSpec:" + widthSpecMode + "--" + widthSpecSize);
            Log.d(TAG, "onMeasure heightMeasureSpec:" + heightSpecMode + "--" + heightSpecSize);
        }
        int width;
        if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) {
            width = Math.min(widthSpecSize, heightSpecSize);
        } else {
            width = Math.min(widthSpecSize, heightSpecSize);
            width = Math.min(width, 300);
        }
        setMeasuredDimension(width, width);
    }

總結

InsLoadingAnimation主要是由屬性動畫實現,也加深了對View的生命周期和事件傳遞等方法的理解。更進一步的,也練習了canvas上的繪圖。最后我們就有了和Instagram和微博一樣炫酷的動畫效果~ 如果你覺得不錯,趕快去https://github.com/qintong91/InsLoadingAnimation star/fork一下吧,歡迎交流和建議~

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

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,024評論 25 708
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,198評論 4 61
  • 當我們獨自背上行囊,開啟一場遠行的時候,便注定要一個人承受旅途里的所有孤獨。 不知道找誰來拍照,記錄旅途中美好的自...
    田錦鈞閱讀 360評論 0 5
  • 知小酌,又名懶小拖,年過3旬,自畫像...還在學習中...,一直對手繪很感興趣,不過人如其名,懶嘛,又拖、拖、拖,...
    知小酌閱讀 246評論 2 1