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

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>