Android 動(dòng)畫(huà)實(shí)戰(zhàn)

前言##

通過(guò)之前的《Android 動(dòng)畫(huà)總結(jié)》,對(duì)常用的Android動(dòng)畫(huà)有了一個(gè)整體認(rèn)識(shí)。但是,之前的內(nèi)容都是概念性的,所列的demo也沒(méi)有實(shí)際意義。這里就通過(guò)兩個(gè)實(shí)例了解一下如何在 實(shí)際開(kāi)發(fā)中運(yùn)用Android 動(dòng)畫(huà)來(lái)實(shí)現(xiàn)一些良好的用戶(hù)體驗(yàn)。

這里通過(guò)展示兩個(gè)常見(jiàn)且較為容易實(shí)現(xiàn)的動(dòng)畫(huà)效果:

仿支付寶支付完成動(dòng)畫(huà)
購(gòu)物車(chē)添加商品動(dòng)畫(huà)

動(dòng)畫(huà)實(shí)戰(zhàn)##

仿支付寶支付完成動(dòng)畫(huà)###

首先看一下效果圖。

alipay

模擬器截取動(dòng)畫(huà)真是醉了

支付成功動(dòng)畫(huà)####

關(guān)于這個(gè)支付成功的動(dòng)畫(huà),通過(guò)之前所說(shuō)的幀動(dòng)畫(huà)(Frame Animation)是可以實(shí)現(xiàn)的,但前提是需要完善的圖片資源。如果UI 沒(méi)有提供圖片資源,那是否就束手無(wú)策了呢?其實(shí)不然,對(duì)于這種構(gòu)圖比較簡(jiǎn)單的動(dòng)畫(huà),還是可以通過(guò)屬性動(dòng)畫(huà)實(shí)現(xiàn)的。

觀察一下這個(gè)動(dòng)畫(huà),首先繪制一個(gè)圓形,圓形完成的同時(shí)繪制“對(duì)號(hào)”,動(dòng)畫(huà)完成的瞬間再執(zhí)行變色和整個(gè)view縮放的效果,同時(shí)修改button上的文字。

那么我們的動(dòng)畫(huà)實(shí)現(xiàn)也是按照這個(gè)順序:

public void loadCircle(int mRadius) {
    mRadius = mRadius <= 0 ? DEFAULT_RADIUS : mRadius;
    this.mRadius = mRadius - PADDING;
    if (null != mAnimatorSet && mAnimatorSet.isRunning()) {
      return;
    }
    reset();
    reMeasure();
    Log.e("left", "R is -------->" + mRadius);
    mCircleAnim = ValueAnimator.ofInt(0, 360);
    mLineLeftAnimator = ValueAnimator.ofFloat(0, this.mRadius / 2f);
    mLineRightAnimator = ValueAnimator.ofFloat(0, this.mRadius / 2f);
    Log.i(TAG, "mRadius" + mRadius);
    mCircleAnim.setDuration(700);
    mLineLeftAnimator.setDuration(350);
    mLineRightAnimator.setDuration(350);
    mCircleAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        mDegree = (Integer) animation.getAnimatedValue();
        invalidate();
      }
    });
    mLineLeftAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator valueAnimator) {
        mLeftValue = (Float) valueAnimator.getAnimatedValue();
        Log.e("left", "-------->" + mLeftValue);
        invalidate();
      }
    });
    mLineRightAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        mRightValue = (Float) animation.getAnimatedValue();
        invalidate();
      }
    });
    mAnimatorSet.play(mCircleAnim).before(mLineLeftAnimator);
    mAnimatorSet.play(mLineRightAnimator).after(mLineLeftAnimator);
    mAnimatorSet.addListener(new AnimatorListenerAdapter() {
      @Override
      public void onAnimationEnd(Animator animation) {
        stop();
        if (mEndListner != null) {
          mEndListner.onCircleDone();
          SuccessAnim();
        }

      }
    });
    mAnimatorSet.start();
  }

我們定義了mCircleAnim,mLineLeftAnimator和mLineRightAnimator 三個(gè)屬性動(dòng)畫(huà),并依次播放三個(gè)動(dòng)畫(huà),同時(shí)在各自的update方法中獲取動(dòng)畫(huà)當(dāng)前的變化值,同時(shí)調(diào)用invalidate() ,這樣就會(huì)不斷執(zhí)行onDraw 方法,不斷繪制新的視圖,產(chǎn)生動(dòng)畫(huà)效果。而在動(dòng)畫(huà)執(zhí)行結(jié)束的時(shí)候,可以執(zhí)行接口中定義的監(jiān)聽(tīng)動(dòng)畫(huà)結(jié)束的方法,這里這么做是為了方便在Activity中執(zhí)行一些動(dòng)畫(huà)結(jié)束后的操作。同時(shí)執(zhí)行了當(dāng)前view 大小縮放的動(dòng)畫(huà)SuccessAnim()。

這里重點(diǎn)看一下onDraw方法,這個(gè)方法可以說(shuō)是實(shí)現(xiàn)整個(gè)動(dòng)畫(huà)最核心的內(nèi)容。

protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mRectF.left = mCenterX - mRadius;
    mRectF.top = mCenterY - mRadius;
    mRectF.right = mCenterX + mRadius;
    mRectF.bottom = mCenterY + mRadius;
    canvas.drawArc(mRectF, 0, mDegree, false, mCirclePanit);
    canvas.drawLine(mCenterX - mRadius / 2, mCenterY,
        mCenterX - mRadius / 2 + mLeftValue, mCenterY + mLeftValue, mLinePaint);
    canvas.drawLine(mCenterX, mCenterY + mRadius / 2,
        mCenterX + mRightValue, mCenterY + mRadius / 2 - (3f / 2f) * mRightValue, mLinePaint);

  }

1.第7行canvas.drawArc 的實(shí)現(xiàn)很容易理解,我們?cè)谥暗膶傩詣?dòng)畫(huà)中,實(shí)現(xiàn)一個(gè)初始值為0,結(jié)束值為360 的ValueAnimator,同時(shí)在其執(zhí)行的過(guò)程中,不斷將中間值賦給mDegree,這樣mDegree值就從0變化到360,從而實(shí)現(xiàn)了一個(gè)圓形繪制。

2.第8行中繪制的是”對(duì)號(hào)“中左邊的短線。第10行繪制的是右邊向上的長(zhǎng)線。這里的思路結(jié)合下圖很容易理解(這只是一個(gè)示意圖,實(shí)際繪制時(shí)右邊長(zhǎng)線的斜率由圓心、半徑多個(gè)值所決定)。

繪制原理

兩點(diǎn)確定一條直線,就是這么簡(jiǎn)單。

之前說(shuō)過(guò),屬性動(dòng)畫(huà)的運(yùn)行機(jī)制是通過(guò)不斷地對(duì)值進(jìn)行操作來(lái)實(shí)現(xiàn)的,而初始值和結(jié)束值之間的動(dòng)畫(huà)過(guò)渡就是由ValueAnimator這個(gè)類(lèi)來(lái)負(fù)責(zé)計(jì)算的。

這里我們就是利用這個(gè)原理實(shí)現(xiàn)了這個(gè)動(dòng)畫(huà)。

理解了這點(diǎn),下面支付失敗的動(dòng)畫(huà),也是相似的原理,中間繪制的內(nèi)容不再是一個(gè)“對(duì)號(hào)”,而是一個(gè)巨大的X。這個(gè)很容易實(shí)現(xiàn),以圓心為坐標(biāo)軸中點(diǎn),在四個(gè)象限45度方向繪制四個(gè)點(diǎn),分別作為起始點(diǎn)和終點(diǎn)即可,結(jié)合代碼很容易理解。

        int mViewWidth = getWidth();
        int mViewHeight = getHeight();
        mCenterX = mViewWidth / 2;
        mCenterY = mViewHeight / 2;

        temp = mRadius / 2.0f * factor;
        Path path = new Path();
        path.moveTo(mCenterX - temp, mCenterY - temp);
        path.lineTo(mCenterX + temp, mCenterY + temp);
        pathLeftMeasure = new PathMeasure(path, false);

        path = new Path();
        path.moveTo(mCenterX + temp, mCenterY - temp);
        path.lineTo(mCenterX - temp, mCenterY + temp);
        pathRightMeasure = new PathMeasure(path, false);

繪制方法onDraw

    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mRectF.left = mCenterX - mRadius;
        mRectF.top = mCenterY - mRadius;
        mRectF.right = mCenterX + mRadius;
        mRectF.bottom = mCenterY + mRadius;
        canvas.drawArc(mRectF, 0, mDegree, false, mCirclePanit);
        if (mLeftPos[1] > (mCenterY - temp) && mRightPos[1] > (mCenterY - temp)) {
            canvas.drawLine(mCenterX - temp, mCenterY - temp, mLeftPos[0], mLeftPos[1], mLinePaint);
            canvas.drawLine(mCenterX + temp, mCenterY - temp, mRightPos[0], mRightPos[1], mLinePaint);
        }
    }

這里的mLeftPos和mRightPos,就是屬性動(dòng)畫(huà)由初始值過(guò)渡到結(jié)束值時(shí),中間變化值所對(duì)應(yīng)的位置。具體可結(jié)合源碼理解。

最后再說(shuō)一下,使用幀動(dòng)畫(huà)的方式實(shí)現(xiàn)這個(gè)動(dòng)畫(huà),為了適配不同的機(jī)型,必然需要多份不同分辨率的圖片,適配效果不得而知,同時(shí)也會(huì)增加應(yīng)用的大小。但是使用幀動(dòng)畫(huà)就不同了,把握好整個(gè)view的大小,適配起來(lái)應(yīng)該相對(duì)會(huì)容易一些。同時(shí)應(yīng)用大小也不會(huì)變化,同時(shí)可擴(kuò)展性也更高。

購(gòu)物車(chē)添加商品動(dòng)畫(huà)###

購(gòu)物添加動(dòng)畫(huà)可以說(shuō)是,屬性動(dòng)畫(huà)最經(jīng)典的例子;很早以前就有人實(shí)現(xiàn)了。這里就從學(xué)習(xí)屬性動(dòng)畫(huà)的角度出發(fā)加以理解。

這里軌跡的繪制并不完全是靠屬性動(dòng)畫(huà)完成,很大一部分的功勞要算在貝塞爾曲線的身上。關(guān)于貝塞爾曲線的理解,可以看看這里

private void addToCarAnimation(ImageView goodsImg) {
    //獲取需要進(jìn)行動(dòng)畫(huà)的ImageView
    final ImageView animImg = new ImageView(mContext);
    animImg.setImageDrawable(goodsImg.getDrawable());
    RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(100, 100);
    shellLayout.addView(animImg, params);
    //
    final int shellLocation[] = new int[2];
    shellLayout.getLocationInWindow(shellLocation);
    int animImgLocation[] = new int[2];
    goodsImg.getLocationInWindow(animImgLocation);
    int carLocation[] = new int[2];
    carImage.getLocationInWindow(carLocation);
    //
    // 起始點(diǎn):圖片起始點(diǎn)-父布局起始點(diǎn)+該商品圖片的一半-圖片的marginTop || marginLeft 的值
    float startX = animImgLocation[0] - shellLocation[0] + goodsImg.getWidth() / 2 - DpConvert.dip2px(mContext, 10.0f);
    float startY = animImgLocation[1] - shellLocation[1] + goodsImg.getHeight() / 2 - DpConvert.dip2px(mContext, 10.0f);

    // 商品掉落后的終點(diǎn)坐標(biāo):購(gòu)物車(chē)起始點(diǎn)-父布局起始點(diǎn)+購(gòu)物車(chē)圖片的1/5
    float endX = carLocation[0] - shellLocation[0] + carImage.getWidth() / 5;
    float endY = carLocation[1] - shellLocation[1];

    //控制點(diǎn),控制貝塞爾曲線
    float ctrlX = (startX + endX) / 2;
    float ctrlY = startY - 100;

    Log.e("num", "-------->" + ctrlX + " " + startY + " " + ctrlY + " " + endY);

    Path path = new Path();
    path.moveTo(startX, startY);
    // 使用二階貝塞爾曲線
    path.quadTo(ctrlX, ctrlY, endX, endY);
    mPathMeasure = new PathMeasure(path, false);

    ObjectAnimator scaleXanim = ObjectAnimator.ofFloat(animImg, "scaleX", 1, 0.5f, 0.2f);
    ObjectAnimator scaleYanim = ObjectAnimator.ofFloat(animImg, "scaleY", 1, 0.5f, 0.2f);

    ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
    valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        // 這里這個(gè)值是中間過(guò)程中的曲線長(zhǎng)度(下面根據(jù)這個(gè)值來(lái)得出中間點(diǎn)的坐標(biāo)值)
        float value = (Float) animation.getAnimatedValue();
        // 獲取當(dāng)前點(diǎn)坐標(biāo)封裝到mCurrentPosition
        // mCurrentPosition此時(shí)就是中間距離點(diǎn)的坐標(biāo)值
        mPathMeasure.getPosTan(value, mCurrentPosition, null);
        // 移動(dòng)的商品圖片(動(dòng)畫(huà)圖片)的坐標(biāo)設(shè)置為該中間點(diǎn)的坐標(biāo)
        animImg.setTranslationX(mCurrentPosition[0]);
        animImg.setTranslationY(mCurrentPosition[1]);
      }
    });

    valueAnimator.addListener(new AnimatorListenerAdapter() {
      @Override
      public void onAnimationEnd(Animator animation) {
        super.onAnimationEnd(animation);
        goodsCount++;
        if (goodsCount < 100) {
          carCount.setText(String.valueOf(goodsCount));
        } else {
          carCount.setText("99+");
        }

        // 把執(zhí)行動(dòng)畫(huà)的商品圖片從父布局中移除
        shellLayout.removeView(animImg);
        shopCarAnim();

      }
    });

    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.setDuration(500);
    animatorSet.setInterpolator(new AccelerateInterpolator());
    animatorSet.playTogether(scaleXanim, scaleYanim, valueAnimator);
    animatorSet.start();

  }

我們分別獲取了整個(gè)布局在手機(jī)屏幕中的位置: shellLocation
所要進(jìn)行動(dòng)畫(huà)的圖片在手機(jī)屏幕中的位置:animLocation
購(gòu)物車(chē)在整個(gè)手機(jī)屏幕中的位置:carLocation

并由這三個(gè)值及動(dòng)畫(huà)圖片的大小布局等因素確定了三個(gè)點(diǎn):

起始位置(startX,startY)、結(jié)束位置(endX,endY)和控制點(diǎn)(CtrlX,CtrlY)。
并由這三個(gè)點(diǎn)確定了一個(gè)二階貝塞爾曲線 path。

同時(shí)使用PathMeasure 類(lèi)測(cè)量這條path,同時(shí)使用它的長(zhǎng)度length 作為屬性動(dòng)畫(huà)中的終點(diǎn)值。

ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
    

在動(dòng)畫(huà)的update回調(diào)方法中,我們獲取這個(gè)長(zhǎng)度過(guò)渡變化的中間值,然后我們使用了一個(gè)很重要的方法

mPathMeasure.getPosTan(value, mCurrentPosition, null);

可以看一下,這個(gè)方法的具體實(shí)現(xiàn)

/**
     * Pins distance to 0 <= distance <= getLength(), and then computes the
     * corresponding position and tangent. Returns false if there is no path,
     * or a zero-length path was specified, in which case position and tangent
     * are unchanged.
     *
     * @param distance The distance along the current contour to sample
     * @param pos If not null, eturns the sampled position (x==[0], y==[1])
     * @param tan If not null, returns the sampled tangent (x==[0], y==[1])
     * @return false if there was no path associated with this measure object
    */
    public boolean getPosTan(float distance, float pos[], float tan[]) {
        if (pos != null && pos.length < 2 ||
            tan != null && tan.length < 2) {
            throw new ArrayIndexOutOfBoundsException();
        }
        return native_getPosTan(native_instance, distance, pos, tan);
    }

這個(gè)方法有三個(gè)參數(shù)

  1. 第一個(gè)參數(shù),MeasuePath 所測(cè)量的path的長(zhǎng)度的當(dāng)前值,也就是我們動(dòng)畫(huà)變化中的過(guò)渡值。

  2. 第二個(gè)參數(shù)是個(gè)數(shù)組,如果不為null,就被賦予當(dāng)前值所對(duì)應(yīng)位置的坐標(biāo)。

  3. 第三個(gè)參數(shù)也是數(shù)組,如果不為null,就被賦予當(dāng)前值所對(duì)應(yīng)的切線坐標(biāo)。(這個(gè)沒(méi)搞懂神馬意思)

如果這個(gè)MeasurePath所測(cè)量的path不存在,就會(huì)返回false。

最終這個(gè)方法會(huì)執(zhí)行一個(gè)native方法,具體實(shí)現(xiàn)我們就不得而知了。

回到我們的代碼,這里我們第二參數(shù),傳入了一個(gè)二維的int 數(shù)組,這樣隨著path總長(zhǎng)度的流逝,我們就依次獲取了這條path線路上的坐標(biāo)點(diǎn)mCurrentPosition。然后通過(guò)設(shè)置動(dòng)畫(huà)圖片animImg 的位置就實(shí)現(xiàn)了動(dòng)畫(huà)效果。

這里重點(diǎn)說(shuō)了一下整體的實(shí)現(xiàn)思路,實(shí)際中還有很多細(xì)節(jié)值得考慮,尤其是在切換為GridView模式的時(shí)候,動(dòng)畫(huà)起點(diǎn)在左右兩邊是有差異的,具體細(xì)節(jié)可參考源碼自己思考。

總結(jié)###

看到這里可以發(fā)現(xiàn),ValueAnimator 這個(gè)類(lèi)雖然很簡(jiǎn)單,但是非常有用。他幫我們實(shí)現(xiàn)了一種屬性值從開(kāi)始到結(jié)束的自然過(guò)渡,而且可以獲取到過(guò)渡過(guò)程的中間值,這樣就很方便我們結(jié)合這個(gè)過(guò)渡值做各種各樣的動(dòng)畫(huà)了。

最后 github 源碼歡迎star & fork


最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,716評(píng)論 25 709
  • 前言## 在應(yīng)用中使用動(dòng)畫(huà),可以給用戶(hù)帶來(lái)良好的交互體驗(yàn)。通過(guò)之前對(duì)Android動(dòng)畫(huà)的分類(lèi)總結(jié),嘗試了使用屬性動(dòng)...
    IAM四十二閱讀 1,403評(píng)論 0 21
  • 在iOS中隨處都可以看到絢麗的動(dòng)畫(huà)效果,實(shí)現(xiàn)這些動(dòng)畫(huà)的過(guò)程并不復(fù)雜,今天將帶大家一窺ios動(dòng)畫(huà)全貌。在這里你可以看...
    每天刷兩次牙閱讀 8,686評(píng)論 6 30
  • 轉(zhuǎn)學(xué)后的凡,第一次段考,成績(jī)極不好,甚至說(shuō)很低的分?jǐn)?shù)。這個(gè)分?jǐn)?shù)和名次,深深刺痛了我,以至于情緒失控,幾次忍不住咆...
    素月1閱讀 347評(píng)論 2 3
  • 圣誕假的時(shí)候,我又去了一次臺(tái)灣。 因?yàn)椴幌肴ヌ涞牡胤?,?1月開(kāi)始,上海就開(kāi)始了無(wú)窮無(wú)盡漫長(zhǎng)的冬季。 也沒(méi)辦法去...
    Couch閱讀 606評(píng)論 0 51

友情鏈接更多精彩內(nèi)容