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