企業微信同事吧下拉刷新動畫的實現分析

suacker 7年前發布 | 8K 次閱讀 安卓開發 Android開發 移動開發

不久前企業微信上線了同事吧的功能,其下拉刷新動畫如上圖圖所示,這個控件對數學公式和技巧的運用是非常巧妙的,可能當你接觸這個動畫的時候會感到有點不知所措,但是當讀完本文,了解到其背后的數學原理后,你會驚奇的發現:實現這個控件也是分分鐘的事情嘛!數學之美就在于它將復雜的具體問題抽象出來,用一種優雅的方式表達出來。

動畫Demo已經上傳至 我的Github 。并且提供了ios版本和Android版本,本文將以android為例講解

我們先分析下這個動畫:它是四個不同顏色的小球,循環移動,每個小球移動所做的動畫類似于“QQ未讀消息氣泡拖拽消失的動畫”,還需要做到的是下拉刷新跟隨手勢移動。

我們給這個 View 起名為 WWLoadingView ,先把基本的骨架搭起來:

public class WWLoadingView extends View{
    public WWLoadingView(Context context, int size) {
        super(context);
        mSize = size;
        init(context);
    }

    public WWLoadingView(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.WWLoadingView);
        mSize = array.getDimensionPixelSize(R.styleable.WWLoadingView_loading_size, 0);
        array.recycle();

        init(context);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(mSize, mSize);
    }

}

然后就是建立模型,我們的四個小球所在的原點是固定的,只要小球的起點確定了,那么終點也是確定,所以我們可以用一個閉合的鏈式結構來描述這種關系:

private static class OriginPoint {
    private float mX;
    private float mY;
    private OriginPoint mNext;

    public OriginPoint(float x, float y) {
        mX = x;
        mY = y;
    }

    public void setNext(OriginPoint next) {
        mNext = next;
    }

    public OriginPoint getNext() {
        return mNext;
    }

    public float getX() {
        return mX;
    }

    public float getY() {
        return mY;
    }
}

在業務上我們就實例化四個點:

OriginPoint op1 = new OriginPoint(mSize / 2, mOriginInset);
OriginPoint op2 = new OriginPoint(mOriginInset, mSize / 2);
OriginPoint op3 = new OriginPoint(mSize / 2, mSize - mOriginInset);
OriginPoint op4 = new OriginPoint(mSize - mOriginInset, mSize / 2);
op1.setNext(op2);
op2.setNext(op3);
op3.setNext(op4);
op4.setNext(op1);

用 next 字段來將四個點聯系起來,這樣后面確定小球動畫的起點和終點時就很容易了。

然后是對小球的抽象:

private static class Ball {
    private float mRadius;
    private float mX;
    private float mY;
    private float mSmallRadius;
    private float mSmallX;
    private float mSmallY;
    private Path mPath;
    private Paint mPaint;
    private OriginPoint mOriginPoint;

    public Ball(float radius, float smallRadius, @ColorInt int color, OriginPoint op) {
        mRadius = radius;
        mSmallRadius = smallRadius;
        mX = mSmallX = op.getX();
        mY = mSmallY = op.getY();
        mPaint = new Paint();
        mPaint.setColor(color);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);
        mPath = new Path();
        mOriginPoint = op;
    }

    public void next() {
        mOriginPoint = mOriginPoint.getNext();
    }
}

并且在業務上實例化四個小球,并傳入 OriginPoint :

mBalls[0] = new Ball(ballRadius, ballSmallRadius, 0xFF0082EF, op1);
mBalls[1] = new Ball(ballRadius, ballSmallRadius, 0xFF2DBC00, op2);
mBalls[2] = new Ball(ballRadius, ballSmallRadius, 0xFFFFCC00, op3);
mBalls[3] = new Ball(ballRadius, ballSmallRadius, 0xFFFB6500, op4);

接下來進入動畫的關鍵環節了,這里我們可以先去看看ISUX對 這篇文章 ,我們小球的動畫每一幀的原理與它是一樣的,所以我也直接拿它的圖來輔助本文的分析了。

通過上圖,我們可以看出,每個 Ball 最終都是有兩個球和以及p1,p2,p3,p4四個點的閉合區間所組成的,在起點或者重點只是兩個小球重疊了而已。 對于我的 Ball 結構,我用 mSmallX , mSmallY , mSmallRadius 確定小圓的大小和位置, mX , mY , mRadius 確定大圓的大小和位置。我們有了這些值,就可以計算出p1,p2,p3,p4四個點的坐標了,這里就要考驗初中三角函數以及幾何圖形的基本功了,來溫習下初中知識:

上圖也只是給出了p1,p3點的計算,實際情況需要更多的計算,除開p2,p4外,還要考慮A點和B點x軸相同或者Y軸相同的情況。

知道如何計算四個點后,我們給我們給 Ball 添加 draw 方法,其實現如下:

public void draw(Canvas canvas) {
    canvas.drawCircle(mX, mY, mRadius, mPaint);
    canvas.drawCircle(mSmallX, mSmallY, mSmallRadius, mPaint);
    if (mSmallX == mX && mSmallY == mY) {
        return;
    }

    /* 三角函數求四個點 */
    float angle;
    float x1, y1, smallX1, smallY1, x2, y2, smallX2, smallY2;
    if (mSmallX == mX) {
        double v = (mRadius - mSmallRadius) / (mY - mSmallY);
        if (v > 1 || v < -1) {
            return;
        }
        angle = (float) Math.asin(v);
        float sin = (float) Math.sin(angle);
        float cos = (float) Math.cos(angle);
        x1 = mX - mRadius * cos;
        y1 = mY - mRadius * sin;
        x2 = mX + mRadius * cos;
        y2 = y1;
        smallX1 = mSmallX - mSmallRadius * cos;
        smallY1 = mSmallY - mSmallRadius * sin;
        smallX2 = mSmallX + mSmallRadius * cos;
        smallY2 = smallY1;
    } else if (mSmallY == mY) {
        double v = (mRadius - mSmallRadius) / (mX - mSmallX);
        if (v > 1 || v < -1) {
            return;
        }
        angle = (float) Math.asin(v);
        float sin = (float) Math.sin(angle);
        float cos = (float) Math.cos(angle);
        x1 = mX - mRadius * sin;
        y1 = mY + mRadius * cos;
        x2 = x1;
        y2 = mY - mRadius * cos;
        smallX1 = mSmallX - mSmallRadius * sin;
        smallY1 = mSmallY + mSmallRadius * cos;
        smallX2 = smallX1;
        smallY2 = mSmallY - mSmallRadius * cos;
    } else {
        double ab = Math.sqrt(Math.pow(mY - mSmallY, 2) + Math.pow(mX - mSmallX, 2));
        double v = (mRadius - mSmallRadius) / ab;
        if (v > 1 || v < -1) {
            return;
        }
        double alpha = Math.asin(v);
        double b = Math.atan((mSmallY - mY) / (mSmallX - mX));
        angle = (float) (Math.PI / 2 - alpha - b);
        float sin = (float) Math.sin(angle);
        float cos = (float) Math.cos(angle);
        smallX1 = mSmallX - mSmallRadius * cos;
        smallY1 = mSmallY + mSmallRadius * sin;
        x1 = mX - mRadius * cos;
        y1 = mY + mRadius * sin;

        angle = (float) (b - alpha);
        sin = (float) Math.sin(angle);
        cos = (float) Math.cos(angle);
        smallX2 = mSmallX + mSmallRadius * sin;
        smallY2 = mSmallY - mSmallRadius * cos;
        x2 = mX + mRadius * sin;
        y2 = mY - mRadius * cos;

    }

    /* 控制點 */
    float centerX = (mX + mSmallX) / 2, centerY = (mY + mSmallY) / 2;
    float center1X = (x1 + smallX1) / 2, center1y = (y1 + smallY1) / 2;
    float center2X = (x2 + smallX2) / 2, center2y = (y2 + smallY2) / 2;
    float k1 = (center1y - centerY) / (center1X - centerX);
    float k2 = (center2y - centerY) / (center2X - centerX);
    float ctrlV = 0.08f;
    float anchor1X = center1X + (centerX - center1X) * ctrlV, anchor1Y = k1 * (anchor1X - center1X) + center1y;
    float anchor2X = center2X + (centerX - center2X) * ctrlV, anchor2Y = k2 * (anchor2X - center2X) + center2y;

    /* 畫貝塞爾曲線 */
    mPath.reset();
    mPath.moveTo(x1, y1);
    mPath.quadTo(anchor1X, anchor1Y, smallX1, smallY1);
    mPath.lineTo(smallX2, smallY2);
    mPath.quadTo(anchor2X, anchor2Y, x2, y2);
    mPath.lineTo(x1, y1);
    canvas.drawPath(mPath, mPaint);
}

我們有了draw方法,但還沒讓小球動起來,接下來我們就看如何讓小球動起來。完成小球的整個移動關鍵是在于兩個圓的圓心的移動,但是移動的速度不同:大球以很快的速度完成移動,而小球則先慢后快,借此形成長尾效應。

提到速度,很多人可能立馬新建幾個速度的變量,這是很直觀的方式,但實現起來不簡單,也并不優雅。我們換一個角度思考:兩個圓的動畫起點和終點都是確定的,運動時間我們也可以固定下來,那么我們確定每個時刻圓的位置就可以了,這就是時間插值器的核心概念了,之前的博文緩動公式小析也是對時間插值器的運用,有興趣的同學可以圍觀。

按照緩動公式小析的分析,我們建立如下的[0,1]到[0,1]的映射:

接下來我們就是把圖形用代碼表達出來就可以了,我們給 Ball 添加方法 calculate ,其傳入一個float值,代表完成時間的百分比,通過這個百分比和上圖的關系計算出當前大圓和小圓的位置信息:

public void calculate(float percent) {
    if (percent > 1f) {
        percent = 1f;
    }
    float v = 1.3f;
    float smallChangePoint = 0.5f, smallV1 = 0.3f;
    float smallV2 = (1 - smallChangePoint * smallV1) / (1 - smallChangePoint);
    // 大圓插值函數表達式
    float ev = Math.min(1f, v * percent);
    float smallEv;
    // 小圓插值表達式函數,它是一個分段函數
    if (percent > smallChangePoint) {
        smallEv = smallV2 * (percent - smallChangePoint) + smallChangePoint * smallV1;
    } else {
        smallEv = smallV1 * percent;
    }

    // mOriginPoint為起點,mOriginPoint.next為終點,通過起點,終點,插值表達式計算小圓和大圓的圓心
    float startX = mOriginPoint.getX();
    float startY = mOriginPoint.getY();
    OriginPoint next = mOriginPoint.getNext();
    float endX = next.getX();
    float endY = next.getY();
    float f = (endY - startY) * 1f / (endX - startX);

    mX = (int) (startX + (endX - startX) * ev);
    mY = (int) (f * (mX - startX) + startY);
    mSmallX = (int) (startX + (endX - startX) * smallEv);
    mSmallY = (int) (f * (mSmallX - startX) + startY);
}

完成了這些,最后一步就是用Animator來連貫的執行這些動畫了:

private void startAnim() {
    stopAnim();
    mAnimator = ValueAnimator.ofFloat(0, 1);
    mAnimator.setDuration(DURATION);
    mAnimator.setRepeatMode(ValueAnimator.RESTART);
    mAnimator.setRepeatCount(ValueAnimator.INFINITE);
    mAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
    mAnimator.setCurrentPlayTime((long) (DURATION * mCurrentPercent));
    mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            mCurrentPercent = (Float) animation.getAnimatedValue();
            for (int i = 0; i < mBalls.length; i++) {
                Ball ball = mBalls[i];
                ball.calculate(mCurrentPercent);
            }
            invalidate();
        }
    });
    mAnimator.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) {
            setToNextPosition();
        }
    });
    mAnimator.start();
}

private void stopAnim() {
    if (mAnimator != null) {
        mAnimator.removeAllUpdateListeners();
        if (Build.VERSION.SDK_INT >= 19) {
            mAnimator.pause();
        }
        mAnimator.end();
        mAnimator.cancel();
        mAnimator = null;
    }
}

private void setToNextPosition() {
    for (int i = 0; i < mBalls.length; i++) {
        Ball ball = mBalls[i];
        ball.next();
    }
}
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    for (int i = 0; i < mBalls.length; i++) {
        mBalls[i].draw(canvas);
    }
}

至此,我們就完成了基本功能,再配合上下拉刷新的控件的代碼,就大功告成了。 對于iOS,我將canvas的繪畫功能換成 layer ,動畫用 CADisplayLink 進行驅動,其它的基本上都是不同語言的相同表述而已。

完成整個動畫關鍵的是數學模型的抽象,當然,最復雜的就是那幾個關鍵點的計算了,這種計算是必不可少的,正如愛因斯坦所說:“Everything should be made as simple as possible, but not simpler”

 

來自:http://blog.cgsdream.org/2017/04/01/wework_pull_refresh_animation/

 

 本文由用戶 suacker 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!