企业微信同事吧下拉刷新动画的实现分析
suacker
8年前
<p><img src="https://simg.open-open.com/show/3deb73420f667b248221f5095e09d7a5.gif"></p> <p>不久前企业微信上线了同事吧的功能,其下拉刷新动画如上图图所示,这个控件对数学公式和技巧的运用是非常巧妙的,可能当你接触这个动画的时候会感到有点不知所措,但是当读完本文,了解到其背后的数学原理后,你会惊奇的发现:实现这个控件也是分分钟的事情嘛!数学之美就在于它将复杂的具体问题抽象出来,用一种优雅的方式表达出来。</p> <p>动画Demo已经上传至 <a href="/misc/goto?guid=4959746576327714067" rel="nofollow,noindex">我的Github</a> 。并且提供了ios版本和Android版本,本文将以android为例讲解</p> <p>我们先分析下这个动画:它是四个不同颜色的小球,循环移动,每个小球移动所做的动画类似于“QQ未读消息气泡拖拽消失的动画”,还需要做到的是下拉刷新跟随手势移动。</p> <p>我们给这个 View 起名为 WWLoadingView ,先把基本的骨架搭起来:</p> <pre> <code class="language-java">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); }</code></pre> <p>}</p> <p>然后就是建立模型,我们的四个小球所在的原点是固定的,只要小球的起点确定了,那么终点也是确定,所以我们可以用一个闭合的链式结构来描述这种关系:</p> <pre> <code class="language-java">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; } }</code></pre> <p>在业务上我们就实例化四个点:</p> <pre> <code class="language-java">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);</code></pre> <p>用 next 字段来将四个点联系起来,这样后面确定小球动画的起点和终点时就很容易了。</p> <p>然后是对小球的抽象:</p> <pre> <code class="language-java">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(); } }</code></pre> <p>并且在业务上实例化四个小球,并传入 OriginPoint :</p> <pre> <code class="language-java">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);</code></pre> <p>接下来进入动画的关键环节了,这里我们可以先去看看ISUX对 <a href="/misc/goto?guid=4959746576435653224" rel="nofollow,noindex">这篇文章</a> ,我们小球的动画每一帧的原理与它是一样的,所以我也直接拿它的图来辅助本文的分析了。</p> <p><img src="https://simg.open-open.com/show/ca80ec3a464818184593c61060752e9a.png"></p> <p>通过上图,我们可以看出,每个 Ball 最终都是有两个球和以及p1,p2,p3,p4四个点的闭合区间所组成的,在起点或者重点只是两个小球重叠了而已。 对于我的 Ball 结构,我用 mSmallX , mSmallY , mSmallRadius 确定小圆的大小和位置, mX , mY , mRadius 确定大圆的大小和位置。我们有了这些值,就可以计算出p1,p2,p3,p4四个点的坐标了,这里就要考验初中三角函数以及几何图形的基本功了,来温习下初中知识:</p> <p><img src="https://simg.open-open.com/show/8362fabf87d5f7f52c9261279d970d98.jpg"></p> <p>上图也只是给出了p1,p3点的计算,实际情况需要更多的计算,除开p2,p4外,还要考虑A点和B点x轴相同或者Y轴相同的情况。</p> <p>知道如何计算四个点后,我们给我们给 Ball 添加 draw 方法,其实现如下:</p> <pre> <code class="language-java">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); }</code></pre> <p>我们有了draw方法,但还没让小球动起来,接下来我们就看如何让小球动起来。完成小球的整个移动关键是在于两个圆的圆心的移动,但是移动的速度不同:大球以很快的速度完成移动,而小球则先慢后快,借此形成长尾效应。</p> <p>提到速度,很多人可能立马新建几个速度的变量,这是很直观的方式,但实现起来不简单,也并不优雅。我们换一个角度思考:两个圆的动画起点和终点都是确定的,运动时间我们也可以固定下来,那么我们确定每个时刻圆的位置就可以了,这就是时间插值器的核心概念了,之前的博文缓动公式小析也是对时间插值器的运用,有兴趣的同学可以围观。</p> <p>按照缓动公式小析的分析,我们建立如下的[0,1]到[0,1]的映射:</p> <p><img src="https://simg.open-open.com/show/2d99dc432809eee915504cfd48b6e8a7.jpg"></p> <p>接下来我们就是把图形用代码表达出来就可以了,我们给 Ball 添加方法 calculate ,其传入一个float值,代表完成时间的百分比,通过这个百分比和上图的关系计算出当前大圆和小圆的位置信息:</p> <pre> <code class="language-java">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); }</code></pre> <p>完成了这些,最后一步就是用Animator来连贯的执行这些动画了:</p> <pre> <code class="language-java">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); } }</code></pre> <p>至此,我们就完成了基本功能,再配合上下拉刷新的控件的代码,就大功告成了。 对于iOS,我将canvas的绘画功能换成 layer ,动画用 CADisplayLink 进行驱动,其它的基本上都是不同语言的相同表述而已。</p> <p>完成整个动画关键的是数学模型的抽象,当然,最复杂的就是那几个关键点的计算了,这种计算是必不可少的,正如爱因斯坦所说:“Everything should be made as simple as possible, but not simpler”</p> <p> </p> <p>来自:http://blog.cgsdream.org/2017/04/01/wework_pull_refresh_animation/</p> <p> </p>