小松的技术博客

六和敬

若今生迷局深陷,射影含沙。便许你来世袖手天下,一幕繁华。 你可愿转身落座,掌间朱砂,共我温酒煮茶。

企业微信同事吧下拉刷新动画的实现分析

不久前企业微信上线了同事吧的功能,其下拉刷新动画如上图图所示,这个控件对数学公式和技巧的运用是非常巧妙的,可能当你接触这个动画的时候会感到有点不知所措,但是当读完本文,了解到其背后的数学原理后,你会惊奇的发现:实现这个控件也是分分钟的事情嘛!数学之美就在于它将复杂的具体问题抽象出来,用一种优雅的方式表达出来。

动画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”

←支付宝← →微信 →