打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
自定义View之案列篇:仿QQ小红点



一直觉得 QQ 的小红点非常具有创新,新颖。要是自己也能实现类似的效果,那怎一个爽字了得。

先来看看它的最终效果:

效果图具有哪些效果:

  1. 在拉伸范围内的拉伸效果

  2. 未拉出拉伸范围释放后的效果

  3. 拉出拉伸范围再拉回的释放后的效果

  4. 拉出拉伸范围释放后的爆炸效果

涉及的相关知识点:

  • onLayout 视图位置

  • saveLayer 图层相关知识

  • Path 的贝赛尔曲线

  • 手势监听

  • ValueAnimator 属性动画

拉伸效果

我们先来讲解第一个知识点,onLayout 方法:

方法预览:

onLayout(boolean changed,
 int left,
 int top,
 int right,
 int bottom)

我记得我第一次接触这个方法的时候对后面两个参数是理解错了,还纠结了很久。先来看看一张示意图就一目了然了:


那么我们可以得出:

       right = left + view.getWidth();  
       bottom = top + view.getHeight();

注意: right 不要理解成视图控件右边距离屏幕右边的距离;bottom 不要理解成视图控件底部距离屏幕底部的距离。

1、在屏幕中心绘制小圆点

先来啾啾效果图,非常简单:

public class QQ_RedPoint extends View {    private Paint mPaint;   //画笔    
   private int mRadius;    
   private PointF mCenterPoint;    
public QQ_RedPoint(Context context) {
       this(context, null);    }    
public QQ_RedPoint(Context context, AttributeSet attrs) {
       this(context, attrs, 0);    }
public QQ_RedPoint(Context context, AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);        
       mPaint = new Paint();        
       mPaint.setColor(Color.RED);        
       mPaint.setAntiAlias(true);        
       mPaint.setStyle(Paint.Style.FILL);        
       mRadius = 20;        
       mCenterPoint = new PointF();    }  
     @Override    protected void onSizeChanged(int w, int h, int oldw, int oldh) {        
     super.onSizeChanged(w, h, oldw, oldh);        
     mCenterPoint.x = w / 2;        
     mCenterPoint.y = h / 2;    }    
     @Override    protected void onDraw(Canvas canvas) {        
     super.onDraw(canvas);        
     canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);    }}

2、小圆点的拉伸效果

先来看看拉伸的效果图:

这里就要讲解第二个知识点,Path 路径贝塞尔曲线。

拉伸的效果图右三部分组成:

  • 中心小圆

  • 跟手指移动的小圆

  • 两个圆之间使用贝塞尔曲线填充

我们把拼接过程放大来看看:

咦,这个形状好熟悉啊,明明我在什么地方见过。怎么越看越觉得像女生用的姨妈巾呢?原来,QQ 这么有深意。

中间圆的效果已经实现了,接着实现跟手指移动的小圆效果:

为了实现手指触摸屏幕跟随手指移动的小圆效果,重写 onTouchEvent 方法(事件不往父控件传递):

   @Override    public boolean onTouchEvent(MotionEvent event) {
           switch (event.getAction()) {
             case MotionEvent.ACTION_DOWN: {                
             mTouch = true;            }            
            break;            
            case MotionEvent.ACTION_UP: {                
            mTouch = false;            }        }        
            mCurPoint.set(event.getX(), event.getY());        
            postInvalidate();        
       return true;    }

注意:onTouchEvent 方法的返回值为 true,若为 false 捕获不到 ACTION_DOWN 以后的手指状态。

接着实现贝塞尔曲线填充效果,这也是本篇的难点,后面的实现就轻松。

Ps 技术很菜,希望绘制的草图能够帮助到您。

从上效果图中分析可得:

贝塞尔曲线 P1P2,起点 P1,控制点 C1C2 的中点 Q0,结束点 P2

那么我们所需要的就是求到 P1 , P2 , Q0 点的坐标系,Q0 的坐标很容易得到,那么我们怎么来求 P1 , P2 坐标呢?下面我画出了怎么求 P1 , P2 坐标的示意图:

根据示意图得到:

 P1x = x0 + r * sina  
 P1y = y0 - r * cosa

进一步推得,需要求得 P1 的坐标,需要知道 a 的角度。根据数学公式: tan(a) = dy / dx 。dx,dy 为两小圆横纵坐标差值。所以推得 a = arctan(dy / dx) 。同理可以求得 P2 , P3 , P4 坐标。

代码实现:

P1 , P2 , P3 , P4 的坐标为:

       float x = mCurPoint.x;        
       float y = mCurPoint.y;        
       float startX = mCenterPoint.x;        
       float startY = mCenterPoint.y;        
       float dx = x - startX;        
       float dy = y - startY;        
double a = Math.atan(dy / dx);        
       float offsetX = (float) (mRadius * Math.sin(a));        
       float offsetY = (float) (mRadius * Math.cos(a));        
       
       // 根据角度计算四边形的四个点        
       float p1x = startX + offsetX;        
       float p1y = startY - offsetY;        
       float p2x = x + offsetX;        
       float p2y = y - offsetY;        
       float p3x = startX - offsetX;        
       float p3y = startY + offsetY;        
       float p4x = x - offsetX;        
       float p4y = y + offsetY;

两小圆圆心连线中点 Q0 的坐标(本赛尔曲线控制点坐标):

       float controlX = (startX + x) / 2;        
       float controlY = (startY + y) / 2;

效果中 Path 的路径区域是个封闭的区域:

       mPath.reset();        
       mPath.moveTo(p1x, p1y);        
       mPath.quadTo(controlX, controlY, p2x, p2y);        
       mPath.lineTo(p4x, p4y);        
       mPath.quadTo(controlX, controlY, p3x, p3y);        
       mPath.lineTo(p1x, p1y);        
       mPath.close();路径绘制完毕,我们来看看 onDraw 方法的绘制:    
     
       @Override    
       protected void onDraw(Canvas canvas) {        
       super.onDraw(canvas);        
       canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);        
       canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);        
       if (mTouch) {            
       calculatePath();            
       canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);            
       canvas.drawPath(mPath, mPaint);        }        
       canvas.restore();        
       super.dispatchDraw(canvas);//绘出该控件的所有子控件    }

注意:我们在 onTouchEvent 方法中,我们并没有对多点触摸进行处理。如果你感兴趣,请继续关注我的博客。

在 onTouchEvent 方法中调用的是 postInvalidate() 从新绘制,从新绘制有两个方法:postInvalidate ,invadite 。

invadite 必须在 UI 线程中调用,而 postInvalidate 内部是由Handler的消息机制实现的,可以在任何线程中调用,效率没有 invadite 高 。

拉伸范围内释放效果

在拉伸范围内手指释放后的效果:

  • 初始位置只显示 TextView 控件。替换掉了以前的小圆点。

  • 点击 TextView 所在区域才能移动 TextView 。

  • 拖动 TextView 且与中心小圆点以贝塞尔曲线连接形成闭合的路径。

  • 距离的拉伸,小圆的半径逐渐减少。

  • 拉伸一定的范围内,释放手指,按着原来的路径返回,且运动到中心点有反弹效果。

我们挨着来实现以上效果。

显示TextView

当前控件继承 ViewGroup ,我这里继承的是 FrameLayout 。我们在初始化的时候添加 TextView 控件:

   private void init() {        
   mPaint = new Paint();        
   mPaint.setColor(Color.RED);        
   mPaint.setAntiAlias(true);        
   mPaint.setStyle(Paint.Style.FILL);        
   mRadius = 20;        
   mCenterPoint = new PointF();        
   mCurPoint = new PointF();        
   mPath = new Path();        
   mDragTextView = new TextView(getContext());        
   LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);        
   mDragTextView.setLayoutParams(lp);        
   mDragTextView.setPadding(10, 10, 10, 10);        
   mDragTextView.setBackgroundResource(R.drawable.tv_bg);        
   mDragTextView.setText('99+');        
   addView(mDragTextView);     }

在 FrameLayout 中添加了 mDragTextView 控件,并对 mDragTextView 控件做了一些基础的设置。对应的 tv_bg 资源文件:

tv_bg.xml:

'1.0' encoding='utf-8'?>
shape xmlns:android='http://schemas.android.com/apk/res/android'>    
corners android:radius='10dp'/>    
solid android:color='#ff0000'/>    
stroke android:color='#0f000000' android:width='1dp'/>
shape>

我们重写 dispatchDraw 方法(view 重写 onDraw 方法 ,viewgroup 重写 dispatchDraw 方法):

 @Override    
 protected void dispatchDraw(Canvas canvas) {        
 canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);        
 canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);        
 canvas.restore();        
 super.dispatchDraw(canvas);    }

效果图:

这里我们需要注意 super.dispatchDraw(canvas); 的位置,放在最后与放在最前效果是不一样的。

   @Override    
   protected void dispatchDraw(Canvas canvas) {        //....绘制操作        
   super.dispatchDraw(canvas);        
   //绘制自身然后绘制子元素  可以理解子控件覆盖在父控件绘制之上    }

   @Override    
   protected void dispatchDraw(Canvas canvas) {        
   super.dispatchDraw(canvas);        
   //....绘制操作    //绘制子控件然后绘制自身  可以理解成父控件绘制覆盖子控件的绘制    }

例,我这里调整一下 super.dispatchDraw(canvas) 的位置:

   @Override    
   protected void dispatchDraw(Canvas canvas) {        
   super.dispatchDraw(canvas);        
   mPaint.setColor(Color.GREEN);//主要是为了区分红色        
   canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);        
   canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);        
   canvas.restore();    }

效果图:

点击TextView拖动效果

点击 TextView 才能拖动文本,说明要触摸到 TextView 的矩形区域。可以通过:

   int x= (int) event.getX();    
   int y= (int) event.getY();    
   
   if(x>=mDragTextView.getLeft()&&x<><=mdragtextview.getbottom()  =""  =""  =""  =""  =""  ="">
   &&y>=mDragTextView.getTop()){      
   mTouch = true;     }

也可以通过:

  Rect rect = new Rect();  
  rect.left = mDragTextView.getLeft();  
  rect.top = mDragTextView.getTop();  
  rect.right = mDragTextView.getWidth() + rect.left;  
  rect.bottom = mDragTextView.getHeight() + rect.top;                
  if (rect.contains((int) event.getX(), (int) event.getY())) {      mTouch = true;   }

获取到所点击区域在 TextView 的矩形之内。

绘制贝塞尔曲线,形成闭合的路径

我们已经求出了各个点的坐标,连接形成闭合的路径。 so easy …

   private void calculatePath() {        
   float x = mCurPoint.x;        
   float y = mCurPoint.y;        
   float startX = mCenterPoint.x;        
   float startY = mCenterPoint.y;        
   float dx = x - startX;        
   float dy = y - startY;        
   double a = Math.atan(dy / dx);        
   float offsetX = (float) (mRadius * Math.sin(a));        
   float offsetY = (float) (mRadius * Math.cos(a));        
   
        // 根据角度计算四边形的四个点        
        float p1x = startX + offsetX;        
        float p1y = startY - offsetY;        
        float p2x = x + offsetX;        
        float p2y = y - offsetY;        
        float p3x = startX - offsetX;        
        float p3y = startY + offsetY;        
        float p4x = x - offsetX;        
        float p4y = y + offsetY;        
        float controlX = (startX + x) / 2;        
        float controlY = (startY + y) / 2;        
        mPath.reset();        
        mPath.moveTo(p1x, p1y);        
        mPath.quadTo(controlX, controlY, p2x, p2y);        
        mPath.lineTo(p4x, p4y);        
        mPath.quadTo(controlX, controlY, p3x, p3y);        
        mPath.lineTo(p1x, p1y);        
        mPath.close();    }

啾啾效果图:

在拉伸的过程当中,小球的大小是没有变化的。

越拉伸,小球越小

我们可以根据拉伸的距离动态改变小球的半径,来达到小球变小的效果。

1、计算中心小球与文本的距离(三角函数):

       float distance = (float) Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));

2、距离越大,小球半径越小:

       int radius = DEFAULT_RADIUS - (int) (distance / 18); //18 根据拉伸情况        
       if (radius <>8) { //拉伸一定值 固定到最小值            radius = 8;        }

然后把效果绘制到画布上面:

   protected void dispatchDraw(Canvas canvas) {        
   canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()),
   mPaint, Canvas.ALL_SAVE_FLAG);        
   if (mTouch) {            
   calculatePath();            
   canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);            
   canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);            
   canvas.drawPath(mPath, mPaint);//将textview的中心放在当前手指位置            
   mDragTextView.setX(mCurPoint.x - mDragTextView.getWidth() / 2);            
   mDragTextView.setY(mCurPoint.y - mDragTextView.getHeight() / 2);        
   }else {            
   mDragTextView.setX(mCenterPoint.x - mDragTextView.getWidth() / 2);            
   mDragTextView.setY(mCenterPoint.y - mDragTextView.getHeight() / 2);        
   
  }        
   canvas.restore();        
   super.dispatchDraw(canvas);    }

看看效果:

拉伸范围内,释放手指后的运动效果

手指释放,在 onTouchEvent方法 MotionEvent.ACTION_UP 中进行处理。

1、判定当前是否拖动文本:

       if (rect.contains((int) event.getRawX(), (int) event.getRawY())) {            
       mTouch = true;            
       mTouchText = true;        
       } else {            
       mTouchText = false;        
     }

2、在 MotionEvent.ACTION_UP 中开启释放的动画:

   case MotionEvent.ACTION_UP:        
   mTouch = false;        
   if (mTouchText) {            
   startReleaseAnimator();        
  }        
   break;

3、释放动画效果:

   private Animator getReleaseAnimator() {        
   final ValueAnimator animator = ValueAnimator.ofFloat(1.0f, 0.0f);        
   animator.setDuration(500);        
   animator.setRepeatMode(ValueAnimator.RESTART);        
   animator.addUpdateListener(new MyAnimatorUpdateListener(this) {            
   @Override            
   public void onAnimationUpdate(ValueAnimator animation) {                
   mReleaseValue = (float) animation.getAnimatedValue();                
   postInvalidate();            
  }        
}
);        
animator.setInterpolator(new OvershootInterpolator());        
   return animator;    }

非常经典的属性动画系列讲解。

animator.setInterpolator(new OvershootInterpolator()); 设置了插值器,OvershootInterpolator 向前甩一定值后再回到原来位置,就可以实现反弹的效果。

通过 (float) animation.getAnimatedValue() 获取动画运到到某一时刻的属性值,然后刷新界面:

1、根据属性值来计算文本的位置:

首先获取文本距离中心小圆的横纵坐标差值:

       float dx = mCurPoint.x - mCenterPoint.x;        
       float dy = mCurPoint.y - mCenterPoint.y;

文本的位置:

   float x = mCurPoint.x - dx * (1.0f - mReleaseValue);    
   float y = mCurPoint.y - dy * (1.0f - mReleaseValue);

dx (1.0f - mReleaseValue) , dy (1.0f - mReleaseValue) 表示在 x 轴,y 轴上的运动距离,根据当前的位置 - 运到的距离 = 文本的位置

获取到文本的位置坐标,又知道中心点坐标,根据上面的公式绘制出闭合的贝塞尔曲线,就很容易了。

2、释放动画过程中,防止多次拖动文本:

       animator.addListener(new AnimatorListenerAdapter() {            
       @Override            
       public void onAnimationEnd(Animator animation) {                
       super.onAnimationEnd(animation);                
       mMoreDragText = true;            }            
       @Override            
       public void onAnimationStart(Animator animation) {                
       super.onAnimationStart(animation);                
       mMoreDragText = false;            
     }        
  }
);

拉伸范围外的效果

拉伸到一定范围外,然后再拉回来释放手指,会发现文本回到了中心并回弹效果;拉伸到范围外释放手指,会出现爆炸效果。

  • 拉伸到范围外再拉回释放效果

  • 拉伸到范围外释放爆炸效果

拉伸到范围外再拉回释放效果

只要有一次拉伸到范围外,再拉回来释放,就不会再绘制中心小圆以及贝塞尔曲线的闭合路径。所以这里需要一个布尔值的标识,只要小圆半径减少到一定值就把标识设置为 true

       if (mRadius == 8) {            
       mOnlyOneMoreThan = true;        
     }

在 dispatchDraw 方法里面绘制文本的位置:

   mDragTextView.setX(mCenterPoint.x - mDragTextView.getWidth() / 2);    
   mDragTextView.setY(mCenterPoint.y - mDragTextView.getHeight() / 2);

拉伸到范围外释放爆炸效果

爆炸效果,是用一张张图片实现的。我们需要添加一个 ImageView 控件来单独播放爆炸的图片,具体步骤如下:

1、新增图片数组:

  private int[] mExplodeImages = new int[]{          
  R.mipmap.idp,          
  R.mipmap.idq,           
  R.mipmap.idr,          
  R.mipmap.ids,           
  R.mipmap.idt};  //爆炸的图片集合

2、新增 ImageView 用于播放爆炸效果:

   mExplodeImage = new ImageView(getContext());    
   mExplodeImage.setLayoutParams(lp);    
   mExplodeImage.setImageResource(R.mipmap.idp);    
   mExplodeImage.setVisibility(View.INVISIBLE);    
   addView(mExplodeImage);

mExplodeImage 设置为不占位不可见。

3、范围外,手指离开,播放爆炸效果:

   private Animator getExplodeAnimator() {        
   ValueAnimator animator = ValueAnimator.ofInt(0, mExplodeImages.length - 1);        
   animator.setInterpolator(new LinearInterpolator());        
   animator.setDuration(1000);        
   animator.addUpdateListener(new MyAnimatorUpdateListener(this) {            
   @Override            
   public void onAnimationUpdate(ValueAnimator animation) {                
   mExplodeImage.setBackgroundResource(mExplodeImages[(int) animation.getAnimatedValue()]);            
 }        
}
);        
  return animator;    
}

mExplodeImage 的位置应该是手指离开的位置:

   private void layoutExplodeImage() {        
   mExplodeImage.setX(mCurPoint.x - mDragTextView.getWidth() / 2);        
   mExplodeImage.setY(mCurPoint.y - mDragTextView.getHeight() / 2);    }


本篇篇幅比较长,设计的知识点比较多。若你有什么不懂疑问的地方,还请留言。

源码地址(https://github.com/HpWens/QQRedPointDemo)


本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
怎么把App变成黑白色
Android动画之萌萌哒蜡烛吹蜡烛动画
10分钟鸿蒙应用实战开发:鸿蒙手绘板 (含源代码)
Android 自绘动画实现与优化实战——以 Tencent OS 录音机波形动
Android实现翻页功能原理
一条神奇的贝塞尔曲线及其应用
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服