Android drawPath实现QQ拖拽泡泡

rong424 4年前
   <p>这两天学习了使用Path绘制贝塞尔曲线相关,然后自己动手做了一个类似QQ未读消息可拖拽的小气泡,效果图如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/614d81ee153462ebce168426acb15d28.gif"></p>    <p style="text-align: center;">最终效果图</p>    <p>接下来一步一步的实现整个过程。</p>    <h2>基本原理</h2>    <p>其实就是使用Path绘制三点的二次方贝塞尔曲线来完成那个妖娆的曲线的。然后根据触摸点不断绘制对应的圆形,根据距离的改变改变原始固定圆形的半径大小。最后就是松手后返回或者爆裂的实现。</p>    <h2>Path介绍:</h2>    <p>顾名思义,就是一个路径的意思,Path里面有很多的方法,本次设计主要用到的相关方法有</p>    <blockquote>     <p><code>moveTo()</code> 移动Path到一个指定的点<br> <code>quadTo()</code> 绘制二次贝塞尔曲线,接收两个点,第一个是控制弧度的点,第二个是终点。<br> <code>lineTo()</code> 就是连线<br> <code>close()</code> 闭合Path路径,<br> <code>reset()</code> 重置Path的相关设置</p>    </blockquote>    <ul>     <li> <p>Path入门热身:</p> <pre>  <code class="language-java">  path.reset();    path.moveTo(200, 200);    //第一个坐标是对应的控制的坐标,第二个坐标是终点坐标    path.quadTo(400, 250, 600, 200);      canvas.drawPath(path, paint);    canvas.translate(0, 200);    //调用close,就会首尾闭合连接    path.close();    canvas.drawPath(path, paint);</code></pre> <p>记得不要在onDraw方法中new <code>Path</code>或者 <code>Paint</code>哟!</p> </li>    </ul>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/f7ab361a317f810ad0be18331d18e5b2.png"></p>    <p style="text-align: center;">Path</p>    <h2>具体实现拆分:</h2>    <p>其实整个过程就是绘制了两个贝塞尔二次曲线的的闭合Path路径,然后在上面添加两个圆形。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/1abd1811fc99a553de10dce437da51de.png"></p>    <p style="text-align: center;">原理图1</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/bcf0004d5d74e9dad893fb6a69d93b5c.png"></p>    <p style="text-align: center;">原理图2</p>    <ul>     <li> <p>闭合的<code>Path</code> 路径实现<strong>从左上点画二次贝塞尔曲线到左下点,左下点连线到右下点,右下点二次贝塞尔曲线到右上点,最后闭合一下!!</strong></p> </li>     <li style="text-align: center;"> <p>相关坐标的确定<br> <strong>这是这次里面的难点之一</strong>,因为涉及到了数学里面的一个sin,cos,tan等等,我其实也忘完了,然后又脑补了一下,废话不多说,直接上图!!</p> <img src="https://simg.open-open.com/show/4b10adb0598cb1b1d0db8e155083ec6a.png"> <p>旋转过程的角标</p> </li>    </ul>    <p>为什么自己要亲自去画一下呢,<strong>因为画了你才知道,在360旋转的过程中,角标体系是有两套的,如果就使用一套来画的话,就画出现在旋转的过程中曲线重叠在一起的情况!</strong></p>    <p>问题已经抛出来了,接下来直接看看代码实现!</p>    <h2>角度确定</h2>    <p>根据贴出来的原理图可以知道,我们可以使用起始圆心坐标和拖拽的圆心坐标,根据反正切函数来得到具体的弧度。</p>    <pre>  <code class="language-java">int dy = Math.abs(CIRCLEY - startY);  int dx = Math.abs(CIRCLEX - startX);   angle = Math.atan(dy * 1.0 / dx);</code></pre>    <p>ok,这里的startX,Y就是移动过程中的坐标。angle就是得到的对应的弧度(角度)。</p>    <h2>相关Path绘制</h2>    <p>前面已经提到在旋转的过程中有两套坐标体系,一开始我也很纠结这个坐标体系要怎么确定,后面又恍然大悟,其实相当于就是一三象限正比例增长,二四象限,反比例增长。</p>    <pre>  <code class="language-java"> flag = (startY - CIRCLEY  ) * (startX- CIRCLEX ) <= 0;   //增加一个flag,用于判断使用哪种坐标体系。</code></pre>    <p>最最重要的来了,绘制相关的Path路径!</p>    <pre>  <code class="language-java"> path.reset();   if (flag) {       //第一个点   path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));     path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));  path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));    path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));  path.close();  canvas.drawPath(path, paint);   } else {       //第一个点       path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));         path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));       path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));         path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));       path.close();       canvas.drawPath(path, paint);   }</code></pre>    <p>这里的代码就是把图片上相关的数学公式Java化而已!</p>    <p>到这里,其实主要的工作就完成的差不多了!<br> 接下来,设置<code>paint</code> 为填充的效果,最后再画两个圆</p>    <pre>  <code class="language-java"> paint.setStyle(Paint.Style.FILL)   canvas.drawCircle(CIRCLEX, CIRCLEY, ORIGIN_RADIO, paint);//默认的   canvas.drawCircle(startX == 0 ? CIRCLEX : startX, startY == 0 ? CIRCLEY : startY, DRAG_RADIO, paint);//拖拽的</code></pre>    <p>就可以绘制出想要的效果了!</p>    <p>这里不得不再说说<code>onTouch</code>的处理!</p>    <pre>  <code class="language-java">  case MotionEvent.ACTION_DOWN://有事件先拦截再说!!              getParent().requestDisallowInterceptTouchEvent(true);              CurrentState = STATE_IDLE;              animSetXY.cancel();              startX = (int) ev.getX();              startY = (int) ev.getRawY();              break;</code></pre>    <p>处理一下事件分发的坑!</p>    <h2>测量和布局</h2>    <p>这样基本过得去了,但是我们的布局什么的还没有处理,math_parent是万万没法使用到具体项目当中去的!<br> 测量的时候,如果发现不是精准模式,那么都手动去计算出需要的宽度和高度。</p>    <pre>  <code class="language-java"> @Override  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);      int modeHeight = MeasureSpec.getMode(heightMeasureSpec);      if (modeWidth == MeasureSpec.UNSPECIFIED || modeWidth == MeasureSpec.AT_MOST) {          widthMeasureSpec = MeasureSpec.makeMeasureSpec(DEFAULT_RADIO * 2, MeasureSpec.EXACTLY);      }      if (modeHeight == MeasureSpec.UNSPECIFIED || modeHeight == MeasureSpec.AT_MOST) {          heightMeasureSpec = MeasureSpec.makeMeasureSpec(DEFAULT_RADIO * 2, MeasureSpec.EXACTLY);      }      super.onMeasure(widthMeasureSpec, heightMeasureSpec);  }</code></pre>    <p>然后在布局变化时,获取相关坐标,确定初始圆心坐标:</p>    <pre>  <code class="language-java">@Override  protected void onSizeChanged(int w, int h, int oldw, int oldh) {      super.onSizeChanged(w, h, oldw, oldh);      CIRCLEX = (int) ((w) * 0.5 + 0.5);      CIRCLEY = (int) ((h) * 0.5 + 0.5);  }</code></pre>    <p>然后清单文件里面就可以这样配置了:</p>    <pre>  <code class="language-java"> <com.lovejjfg.circle.DragBubbleView      android:id="@+id/dbv"      android:layout_width="wrap_content"      android:layout_height="wrap_content"      android:layout_gravity="center"/></code></pre>    <p>这样之后,又会出现一个问题,<strong>那就是<code>wrap_content</code> 之后,这个View能绘制的区域只有自身那么大了,拖拽了都看不见了!</strong>这个坑怎么办呢,其实很简单,父布局加上<code>android:clipChildren="false"</code> 的属性!<br> 这个坑也算是解决了!!</p>    <h2>相关状态的确定</h2>    <p>我们是不希望它可以无限的拖拽的,就是有一个拖拽的最远距离,还有就是放手后的返回,爆裂。那么对应的,这里需要确定几种状态:</p>    <pre>  <code class="language-java">  private final static int STATE_IDLE = 1;//静止的状态      private final static int STATE_DRAG_NORMAL = 2;//正在拖拽的状态      private final static int STATE_DRAG_BREAK = 3;//断裂后的拖拽状态      private final static int STATE_UP_BREAK = 4;//放手后的爆裂的状态      private final static int STATE_UP_BACK = 5;//放手后的没有断裂的返回的状态      private final static int STATE_UP_DRAG_BREAK_BACK = 6;//拖拽断裂又返回的状态      private int CurrentState = STATE_IDLE;    private int MIN_RADIO = (int) (ORIGIN_RADIO * 0.4);//最小半径      private int MAXDISTANCE = (int) (MIN_RADIO * 13);//最远的拖拽距离</code></pre>    <p>确定好这些之后,在move的时候,就要去做相关判断了:</p>    <pre>  <code class="language-java">  case MotionEvent.ACTION_MOVE://移动的时候              startX = (int) ev.getX();              startY = (int) ev.getY();                updatePath();              invalidate();              break;    private void updatePath() {      int dy = Math.abs(CIRCLEY - startY);      int dx = Math.abs(CIRCLEX - startX);        double dis = Math.sqrt(dy * dy + dx * dx);      if (dis <= MAXDISTANCE) {//增加的情况,原始半径减小          if (CurrentState == STATE_DRAG_BREAK || CurrentState == STATE_UP_DRAG_BREAK_BACK) {              CurrentState = STATE_UP_DRAG_BREAK_BACK;          } else {              CurrentState = STATE_DRAG_NORMAL;          }          ORIGIN_RADIO = (int) (DEFAULT_RADIO - (dis / MAXDISTANCE) * (DEFAULT_RADIO - MIN_RADIO));          Log.e(TAG, "distance: " + (int) ((1 - dis / MAXDISTANCE) * MIN_RADIO));          Log.i(TAG, "distance: " + ORIGIN_RADIO);      } else {          CurrentState = STATE_DRAG_BREAK;      }  //        distance = dis;      flag = (startY - CIRCLEY) * (startX - CIRCLEX) <= 0;      Log.i("TAG", "updatePath: " + flag);      angle = Math.atan(dy * 1.0 / dx);  }</code></pre>    <p><code>updatePath()</code> 的方法之前已经看过部分了,这次的就是完整的。<br> 这里做的事就是根据拖拽的距离更改相关的状态,并根据百分比来修改原始圆形的半径大小。还有就是之前介绍的确定相关的弧度!</p>    <p>最后放手的时候:</p>    <pre>  <code class="language-java">   case MotionEvent.ACTION_UP:              if (CurrentState == STATE_DRAG_NORMAL) {                  CurrentState = STATE_UP_BACK;                  valueX.setIntValues(startX, CIRCLEX);                  valueY.setIntValues(startY, CIRCLEY);                  animSetXY.start();              } else if (CurrentState == STATE_DRAG_BREAK) {                  CurrentState = STATE_UP_BREAK;                  invalidate();              } else {                  CurrentState = STATE_UP_DRAG_BREAK_BACK;                  valueX.setIntValues(startX, CIRCLEX);                  valueY.setIntValues(startY, CIRCLEY);                  animSetXY.start();              }              break;</code></pre>    <p>自动返回这里使用到的 <code>ValueAnimator</code>,</p>    <pre>  <code class="language-java"> animSetXY = new AnimatorSet();        valueX = ValueAnimator.ofInt(startX, CIRCLEX);      valueY = ValueAnimator.ofInt(startY, CIRCLEY);      animSetXY.playTogether(valueX, valueY);      valueX.setDuration(500);      valueY.setDuration(500);      valueX.setInterpolator(new OvershootInterpolator());      valueY.setInterpolator(new OvershootInterpolator());      valueX.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {          @Override          public void onAnimationUpdate(ValueAnimator animation) {              startX = (int) animation.getAnimatedValue();              Log.e(TAG, "onAnimationUpdate-startX: " + startX);              invalidate();          }        });      valueY.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {          @Override          public void onAnimationUpdate(ValueAnimator animation) {              startY = (int) animation.getAnimatedValue();              Log.e(TAG, "onAnimationUpdate-startY: " + startY);              invalidate();            }      });</code></pre>    <p>最后在看看完整的<code>onDraw</code>方法吧!</p>    <pre>  <code class="language-java"> @Override  protected void onDraw(Canvas canvas) {      switch (CurrentState) {          case STATE_IDLE://空闲状态,就画默认的圆              if (showCircle) {                  canvas.drawCircle(CIRCLEX, CIRCLEY, ORIGIN_RADIO, paint);//默认的              }              break;          case STATE_UP_BACK://执行返回的动画          case STATE_DRAG_NORMAL://拖拽状态 画贝塞尔曲线和两个圆              path.reset();              if (flag) {                  //第一个点                  path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));                    path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));                  path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));                    path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));                  path.close();                  canvas.drawPath(path, paint);              } else {                  //第一个点                  path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));                    path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));                  path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));                    path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));                  path.close();                  canvas.drawPath(path, paint);              }              if (showCircle) {                  canvas.drawCircle(CIRCLEX, CIRCLEY, ORIGIN_RADIO, paint);//默认的                  canvas.drawCircle(startX == 0 ? CIRCLEX : startX, startY == 0 ? CIRCLEY : startY, DRAG_RADIO, paint);//拖拽的              }              break;            case STATE_DRAG_BREAK://拖拽到了上限,画拖拽的圆:          case STATE_UP_DRAG_BREAK_BACK:              if (showCircle) {                  canvas.drawCircle(startX == 0 ? CIRCLEX : startX, startY == 0 ? CIRCLEY : startY, DRAG_RADIO, paint);//拖拽的              }              break;            case STATE_UP_BREAK://画出爆裂的效果              canvas.drawCircle(startX - 25, startY - 25, 10, circlePaint);              canvas.drawCircle(startX + 25, startY + 25, 10, circlePaint);              canvas.drawCircle(startX, startY - 25, 10, circlePaint);              canvas.drawCircle(startX, startY, 18, circlePaint);              canvas.drawCircle(startX - 25, startY, 10, circlePaint);              break;        }      }</code></pre>    <p>到这里,成品就出来了!!</p>    <h2>总结:</h2>    <p>1、确定默认圆形的坐标;<br> 2、根据move的情况,实时获取最新的坐标,根据移动的距离(确定出角度),更新相关的状态,画出相关的Path路径。超出上限,不再画Path路径。<br> 3、松手时,根据相关的状态,要么带Path路径执行动画返回,要么不带Path路径直接返回,要么直接爆裂!</p>    <p>下一篇 <a href="http://www.open-open.com/lib/view/open1466382713157.html">Android drawTextOnPath 水果忍者跳动的文字</a></p>    <p>相关源码请<a href="/misc/goto?guid=4959674740836078053">移步Github</a>,喜欢就请Start或者 fork一下吧,有问题欢迎留言或者issue。。</p>    <p><br> 文/<a href="/misc/goto?guid=4959674740925891098">lovejjfg</a>(简书)<br>  </p>