四步实现ChromeLikeSwipeLayout效果

XHESergio 7年前
   <h2>先看最后效果</h2>    <p style="text-align:center"><img src="https://simg.open-open.com/show/eb0b7d624f0b6d175b8464449f10ea7f.gif"></p>    <p style="text-align:center">DemoPreview.gif</p>    <h2>SETP1 水滴效果</h2>    <p>看到水滴效果第一反应是画一条闭合曲线,随着MotionEvent事件,改变绘制过程中的半径,完成拉伸效果。</p>    <p>1.1 画一条曲线</p>    <p>在android如何画一条曲线?</p>    <ul>     <li>a) 使用canvas.drawCircle</li>     <li>b) 使用canvas.drawOval</li>     <li>c) 使用canvas.drawArc</li>     <li>d) 往path里添加贝塞尔曲线,使用canvas.drawPath画出路径</li>    </ul>    <p>由于考虑到以后需要更换半径参数,而且为了逼真的拉伸效果,一边的半径要短,一边的半径要长,所以方案a、b、c都得舍弃,好在d方案提供了更自由的绘制方案。</p>    <pre>  <code class="language-java">// android.graphics.Path  // quadTo为二次贝塞尔曲线,x1,y1点为控制点,x2,y2为结束点  public void quadTo(float x1, float y1, float x2, float y2) ;    // cubicTo为三次贝塞尔曲线,x1,y1点和x2,y2点为控制点,x3,y3为结束点  public void cubicTo(float x1, float y1, float x2, float y2,   float x3, float y3);</code></pre>    <p>与画直线lineTo类似,需要提供结束点;不同的是贝塞尔曲线需要提供控制点,控制曲线的曲率(弯曲的程度),二次贝塞尔曲线提供一个控制点,三次贝塞尔曲线需要提供两个点。</p>    <p>1.2 画正圆</p>    <p>控制点是如何控制曲率的?</p>    <p>贝塞尔曲线有三个参数:起点、结束点、控制点,它们都是已经确定的定值,由这三个参数确定一条唯一的贝塞尔曲线。</p>    <p>二次贝塞尔曲线如下图1所示,P0为起点,P2为结束点,P1为控制点,P0、P1、P2依次连线,把线段P0P1和线段P1P2平均分为n段,Q0点从P0点向P1点以步长P0P1/n开始匀速运动,Q1点从P1点向P2点以步长P1P2/n开始匀速运动,线Q0Q1的连线即为曲线的切点,所有切点连起来即为二次贝塞尔曲线。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/5e0c0f6603246acfa72fdcf002b16197.png"></p>    <p style="text-align:center">图1 二次贝塞尔曲线 ref[1]</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/21c0261c945a079446bd129622bac9fb.gif"></p>    <p style="text-align:center">图2 二次贝塞尔曲线 ref[1]</p>    <p>三次贝塞尔曲线同理,三次贝塞尔曲线多了一层R0、R1,R0R1的连线即为曲线的切点。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d2526de13cb95adfeed88f3b0dcfa65a.png"></p>    <p>图3 三次贝塞尔曲线 ref[1]</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/4ed064f5183fcfa327be33d9336232a1.gif"></p>    <p>图4 三次贝塞尔曲线 ref[1]</p>    <ul>     <li> <p>如何用贝塞尔曲线画正圆?</p> <p>通过 <a href="/misc/goto?guid=4959742220033880382" rel="nofollow,noindex">使用贝塞尔曲线拟合圆</a> 这篇文章,我们可以知道只要使用三次贝塞尔曲线,且控制点P1在(x1,0),控制点P2在(1,y1),即可绘制出1/4正圆,而这个x1点是一个定值0.55228475,y1为(1-0.55228475)。</p> </li>    </ul>    <p> </p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d869aaba960a07602d4042803f71043e.png"></p>    <p style="text-align:center">图5 拟合正圆 ref[2]</p>    <p>推广一下,画出四条三次贝塞尔曲线,即可绘制出正圆</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/48456be45648abae45ac793b4c5cf6f6.png"></p>    <p style="text-align:center">图6 拟合正圆控制点坐标</p>    <p>绘制图6的四条贝塞尔曲线,代码实现如下:</p>    <pre>  <code class="language-java">private void updatePath(){      mPath.reset();      mPath.lineTo(0, -radius);      mPath.cubicTo(radius * sMagicNumber, -radius              , radius, -radius * sMagicNumber              , radius, 0);      mPath.lineTo(0, 0);        mPath.lineTo(0, radius);      mPath.cubicTo(radius * sMagicNumber, radius              , radius, radius * sMagicNumber              , radius, 0);      mPath.lineTo(0, 0);        mPath.lineTo(0, -radius);      mPath.cubicTo(-radius * sMagicNumber, -radius              , -radius, -radius * sMagicNumber              , -radius, 0);      mPath.lineTo(0, 0);        mPath.lineTo(0, radius);      mPath.cubicTo(-radius * sMagicNumber, radius              , -radius, radius * sMagicNumber              , -radius, 0);      mPath.lineTo(0, 0);        invalidate();  }    @Override  protected void onDraw(Canvas canvas) {      canvas.save();      canvas.translate(centerX, centerY);      canvas.drawPath(mPath, mPaint);      canvas.restore();  }</code></pre>    <p>1.3 拉伸效果</p>    <p>既然已经实现了图6中的贝塞尔曲线绘制正圆,那么拉伸效果只需要减少左半边r长度,增加右半边r长度,即可实现拉伸效果,点坐标如图7所示,lr为较长边半径,sr为较短边半径。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/55e79014690bd7c5f62286e0fbb0a18e.png"></p>    <p style="text-align:center">图7 拉伸效果控制点坐标</p>    <p>1.4 扩展:360旋转一下</p>    <p>在ACTION_DOWN时记录下按下点(prevX,prevY),在ACTION_MOVE时某一点为(currentX,currentY),计算两点的距离即为lr的长度,顺便换算出sr长度</p>    <pre>  <code class="language-java">private void updatePath(float x, float y ){      float distance = distance(mPrevX,mPrevY,x,y);      float longRadius = radius  + distance;      float shortRadius = radius - distance * 0.1f;      ...  }  public static float distance(float x1,float y1, float x2, float y2){      return (float) Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));  }</code></pre>    <p>同时,可以根据(prevX,prevY)、(currentX,currentY)计算出两点的水平夹角,即:</p>    <pre>  <code class="language-java">private static float points2Degrees(float x1, float y1, float x2, float y2){      double angle = Math.atan2(y2-y1,x2-x1);      return (float) Math.toDegrees(angle);  }</code></pre>    <p>剩下只需要在onDraw时把画布rotate一下,就可以实现简单的水滴效果了,效果如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/1ae2557c52980aeb544c5e49039a12e0.gif"></p>    <p style="text-align:center">图8 demo.gif</p>    <p>完整代码如下:</p>    <pre>  <code class="language-java">/**   * Created by hzqiujiadi on 15/11/18.   * hzqiujiadi ashqalcn@gmail.com   */  public class ChromeLikeView extends View  {      private static final String TAG = "ChromeLikeView";      private static final float sMagicNumber = 0.55228475f;      private Paint mPaint;      private Path mPath;      private float mPrevX;      private float mPrevY;      private float mDegrees;      private boolean mIsDown;      private int radius = 120;        public ChromeLikeView(Context context) {          super(context);          init();      }        public ChromeLikeView(Context context, AttributeSet attrs) {          super(context, attrs);          init();      }        public ChromeLikeView(Context context, AttributeSet attrs, int defStyleAttr) {          super(context, attrs, defStyleAttr);          init();      }        private void init() {          mPaint = new Paint();          mPaint.setColor(0xFFDD0011);          mPaint.setStyle(Paint.Style.STROKE);          mPaint.setStrokeWidth(10);          mPath = new Path();          updatePath(0, 0);      }        private void updatePath(float x, float y ){          float distance = distance(mPrevX,mPrevY,x,y);          float longRadius = radius  + distance;          float shortRadius = radius - distance * 0.1f;          mDegrees = points2Degrees(mPrevX,mPrevY,x,y);            mPath.reset();            mPath.lineTo(0, -radius);          mPath.cubicTo(radius * sMagicNumber, -radius                  , longRadius, -radius * sMagicNumber                  , longRadius, 0);          mPath.lineTo(0, 0);            mPath.lineTo(0, radius);          mPath.cubicTo(radius * sMagicNumber, radius                  , longRadius, radius * sMagicNumber                  , longRadius, 0);          mPath.lineTo(0, 0);            mPath.lineTo(0, -radius);          mPath.cubicTo(-radius * sMagicNumber, -radius                  , -shortRadius, -radius * sMagicNumber                  , -shortRadius, 0);          mPath.lineTo(0, 0);            mPath.lineTo(0, radius);          mPath.cubicTo(-radius * sMagicNumber, radius                  , -shortRadius, radius * sMagicNumber                  , -shortRadius, 0);          mPath.lineTo(0, 0);            invalidate();      }        @Override      public boolean onTouchEvent(MotionEvent event) {          int action = event.getAction();          switch ( action ){              case MotionEvent.ACTION_DOWN:                  mIsDown = true;                  mPrevX = event.getX();                  mPrevY = event.getY();                  break;              case MotionEvent.ACTION_MOVE:                  if ( !mIsDown ) break;                  updatePath( event.getX(), event.getY());                  break;              case MotionEvent.ACTION_UP:              case MotionEvent.ACTION_CANCEL:                  if ( !mIsDown ) break;                  float endX = event.getX();                  float endY = event.getY();                  mIsDown = false;                  updatePath(mPrevX,mPrevY);                  break;          }          return true;      }        public static float distance(float x1,float y1, float x2, float y2){          return (float) Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));      }        @Override      protected void onDraw(Canvas canvas) {          int centerX = getMeasuredWidth() >> 1;          int centerY = getMeasuredHeight() >> 1;            canvas.drawColor(0xFFDDDDDD);          canvas.save();          canvas.translate(centerX, centerY);          canvas.rotate(mDegrees);          canvas.drawPath(mPath, mPaint);          canvas.restore();      }        private static float points2Degrees(float x1, float y1, float x2, float y2){          double angle = Math.atan2(y2-y1,x2-x1);          return (float) Math.toDegrees(angle);      }  }</code></pre>    <h2>STEP2 为何回调不到onInterceptTouchEvent?</h2>    <p>ChromeLikeSwipeLayout要实现一个类似下拉刷新的功能,需要在ChromeLikeSwipeLayout的onInterceptTouchEvent中判断是否到了下拉的触发时机:比如ScrollView到了顶端并且一直在往下拉,这时需要拦截ScrollView的MotionEvent.MOVE事件,把事件回调到ChromeLikeSwipeLayout的onTouchEvent中,设置ScrollView的TopOffset,完成下拉操作。</p>    <p>如果ChromeLikeSwipeLayout要下拉的子View不是ScrollView、ListView,就会发现ChromeLikeSwipeLayout的onInterceptTouchEvent只会在MotionEvent.ACTION_DOWN时回调,之后不管怎么滑动都不会调用到onInterceptTouchEvent。</p>    <p>2.1 ViewGroup dispatchTouchEvent流程</p>    <p>由于我之前的文章 Android TouchEvent之requestDisallowInterceptTouchEvent 阅读源代码不仔细,漏掉了一个重要的判断条件,为解决这个问题,不得不再仔细阅读下ViewGroup dispatchTouchEvent的代码源代码,更新后的ViewGroup dispatchTouchEvent流程图如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/63a28780811e43b4a36d5d9b382843c0.png"></p>    <p style="text-align:center">图9 ViewGroup dispatchTouchEvent流程</p>    <p>在TouchEvent dispatchTouchEvent到某ViewGroup中时,会有四步判断,如上图浅绿色所示。</p>    <ol>     <li> <p>ACTION_DOWN or mFirstTouchTarget != null</p> <p>ACTION_DOWN是一个TOUCH事件的开始节点,在ACTION_DOWN事件时就确定了哪些View会来处理后续的一整串TOUCH事件。确定好的对象以链式数据结构存储在mFirstTouchTarget中,如果mFirstTouchTarget不为null,则说明这个ViewGroup的子View处理过TOUCH事件,后续的事件也会进入到此ViewGroup的OnInterceptTouchEvent便于事件拦截。</p> </li>     <li> <p>disallowIntercept?</p> <p>disallowIntercept的作用</p> <p>ViewGroup有一个disallowIntercept开关,可以设置此ViewGroup是否屏蔽onInterceptTouchEvent事件。如果开启此开关,则此ViewGroup跳过自身的onInterceptTouchEvent事件,直接dispatchTouchEvent到子View。</p> <p>重置disallowIntercept</p> <p>disallowIntercept,会在每次ACTION_DOWN被重置,默认为允许调用onInterceptTouchEvent。</p> <p>每次用户的按下滑动抬起操作为一组完整的操作。新一组操作开始,即当用户开始点击屏幕的时候,ViewGroup会重置当前的disallowIntercept开关,恢复到允许调用onInterceptTouchEvent状态。</p> </li>     <li> <p>intercept?</p> <p>onInterceptTouchEvent返回值为true</p> <p>当调用ViewGroup的onInterceptTouchEvent后返回值为true,则表示当前ViewGroup拦截了此TouchEvent事件,此ViewGroup的onTouchEvent会收到回调;</p> <p>onInterceptTouchEvent返回值为false</p> <p>如果返回值为false,则调用dispatchTransformedTouchEvent,去寻找此Point上hit到的子View,如果寻找到子View,则调用子View的dispatchTouchEvent事件,否则就调用super.dispatchTouchEvent,即调用View的dispatchTouchEvent实现,在此会调用到onTouchEvent函数去处理此TouchEvent事件。</p> </li>     <li> <p>handled?</p> <p>onTouchEvent返回值为true</p> <p>如果返回值为true,则此TouchEvent被处理完毕</p> <p>onTouchEvent返回值为false</p> <p>如果为false,则return给父ViewGroup,父ViewGroup会继续交给此ViewGroup的兄弟View处理。</p> </li>    </ol>    <p>另外值得注意的是,</p>    <ul>     <li>只有在ACTION_DOWN、ACTION_POINTER_DOWN、ACTION_HOVER_MOVE时,才会有机会遍历此ViewGroup的子View去生成mFirstTouchTarget,随后的事件都会交给mFirstTouchTarget处理,而不是再次遍历子View。</li>     <li>由于在某ViewGroup中,覆盖在较上层的View理应最先处理TOUCH事件,所以在ViewGroup遍历子View时从childrenCount - 1遍历到0,代码如下:</li>    </ul>    <pre>  <code class="language-java">// ViewGroup#dispatchTouchEvent    if (actionMasked == MotionEvent.ACTION_DOWN          || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)          || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {      ...      if (newTouchTarget == null && childrenCount != 0) {          ....          for (int i = childrenCount - 1; i >= 0; i--) {              final int childIndex = customOrder                      ? getChildDrawingOrder(childrenCount, i) : i;              final View child = (preorderedList == null)                      ? children[childIndex] : preorderedList.get(childIndex);              ....              if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {                  // Child wants to receive touch within its bounds.                  ...                  mLastTouchDownX = ev.getX();                  mLastTouchDownY = ev.getY();                  newTouchTarget = addTouchTarget(child, idBitsToAssign);                  alreadyDispatchedToNewTouchTarget = true;                  break;              }              ...          }          ...      }      ...    }</code></pre>    <p>2.2 onInterceptTouchEvent总结</p>    <ul>     <li>ACTION_DOWN<br> 遍历所有的父ViewGroup->子ViewGroup->孙ViewGroup->目标View,此时在目标View的onTouchEvent中返回true后,父ViewGroup、子ViewGroup、孙ViewGroup都会记录各自子View的mFirstTouchTarget</li>     <li>ACTION_MOVE<br> 如果之后有ACTION_MOVE过来,此时上述ViewGroup的mFirstTouchTarget都不为空,onInterceptTouchEvent流程为父ViewGroup->子ViewGroup->孙ViewGroup,如果其中一个ViewGroup拦截了事件,则此ViewGroup就会处理onTouchEvent事件,且TouchEvent不在往下dispatch,而是开始return;如果没有任何一个ViewGroup在onInterceptTouchEvent时拦截了这个事件,则会调用到目标View中,不管返回true or false,新来的事件也会调用进来。</li>    </ul>    <p>所以想要在ChromeLikeSwipeLayout中回调onInterceptTouchEvent,在ACTION_DOWN时子View就需要在onTouchEvent中return true,ScrollView、ListView这些可以处理滑动的View都是这么做的,而LinearLayout、RelativeLayout则默认return false,所以我们需要在这些不处理onTouchEvent事件的View外面嵌套一个TouchAlwaysTrueLayout,这样就所有类型的子View都可以处理下拉了,代码如下:</p>    <pre>  <code class="language-java">/**   * Created by hzqiujiadi on 15/11/23.   * hzqiujiadi ashqalcn@gmail.com   */  public class TouchAlwaysTrueLayout extends ViewGroup {      public TouchAlwaysTrueLayout(Context context) {          super(context);      }        @Override      public boolean onTouchEvent(MotionEvent event) {          return true;      }        public static ViewGroup wrap(View view){          Context context = view.getContext();          TouchAlwaysTrueLayout wrapper = new TouchAlwaysTrueLayout(context);          wrapper.addView(view, LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);          return wrapper;      }        @Override      protected void onLayout(boolean changed, int l, int t, int r, int b) {          for ( int i = 0 ; i < getChildCount() ; i++ ){              getChildAt(i).layout(l,t,r,b);          }      }        @Override      protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {          super.onMeasure(widthMeasureSpec, heightMeasureSpec);          measureChildren(widthMeasureSpec,heightMeasureSpec);      }  }</code></pre>    <h2>STEP3 从零到一</h2>    <p>所有动画都是从0到1的变化,然后使用插值器Interpolator对从0到1的过程中产生的值进行变化,可以得到不同的动画效果。</p>    <p>3.1 回弹效果</p>    <p>在松开水滴的拖拽后,水滴有个回弹效果,就是利用动画让updatePath(newX,newY)运动到updatePath(mPrevX,mPrevY)即可,再利用BounceInterpolator进行数值上的来回抖动,就可以产生回弹效果,值分布曲线为:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/0f61c90814c39ecd2e57baf1aee6444d.png"></p>    <p style="text-align:center">图10 BounceInterpolator弹跳插值器 ref[3]</p>    <p>3.2 差异化放大</p>    <p>假设下拉过程时从0到1的变化,则icon需要从0.5时刻开始变大,水滴则从0.7时刻开始变大,这样就产生了动画的层次感,变化过程可以描述为:</p>    <table>     <thead>      <tr>       <th>下拉程度</th>       <th>0</th>       <th>0.1</th>       <th>0.2</th>       <th>0.3</th>       <th>0.4</th>       <th>0.5</th>       <th>0.6</th>       <th>0.7</th>       <th>0.8</th>       <th>0.9</th>       <th>1</th>      </tr>     </thead>     <tbody>      <tr>       <td>icon scale</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0.2</td>       <td>0.4</td>       <td>0.6</td>       <td>0.8</td>       <td>1</td>      </tr>      <tr>       <td>水滴 scale</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0</td>       <td>0.3</td>       <td>0.7</td>       <td>1</td>      </tr>     </tbody>    </table>    <p style="text-align:center"> </p>    <p>差异变化传入0到1,获得上表中的数值变化,代码如下:</p>    <pre>  <code class="language-java">private static final float sFactorScaleCircle = 0.75f;  private static final float sFactorScaleIcon = 0.3f;    private float circleOffsetFraction( float fraction ){      return offsetFraction(fraction, sFactorScaleCircle);  }    private float iconOffsetFraction( float fraction ){      return offsetFraction(fraction, sFactorScaleIcon);  }    private float offsetFraction(float fraction, float factor){      float result = (fraction - factor) / (1 - factor);      result = result > 0 ? result : 0;      return result;  }</code></pre>    <h2>STEP4 完善</h2>    <p>再随便添加点代码,就完成了。</p>    <h2>Reference</h2>    <p>[1] wiki <a href="/misc/goto?guid=4959742220126159107" rel="nofollow,noindex">Bézier curve</a></p>    <p>[2] 江一郎 <a href="/misc/goto?guid=4959742220033880382" rel="nofollow,noindex">使用贝塞尔曲线拟合圆</a></p>    <p>[3] 李海珍 <a href="/misc/goto?guid=4959742220230344892" rel="nofollow,noindex">android动画(一)Interpolator</a></p>    <h2> </h2>    <p> </p>    <p>来自:http://www.jianshu.com/p/d6b4a9ad022e</p>    <p> </p>