美文网首页Android学习Android 自定义viewPath
自定义View之案列篇(三):仿QQ小红点

自定义View之案列篇(三):仿QQ小红点

作者: 文淑 | 来源:发表于2016-12-05 14:30 被阅读243次

    光棍节快到了,提前祝愿广大的单身猿猴,早日脱单,尽快找到另一半。

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

    先来看看它的最终效果:

    red

    效果图具有哪些效果:

    1. 在拉伸范围内的拉伸效果
    2. 未拉出拉伸范围释放后的效果
    3. 拉出拉伸范围再拉回的释放后的效果
    4. 拉出拉伸范围释放后的爆炸效果

    涉及的相关知识点:

    • onLayout 视图位置

    • saveLayer 图层相关知识

    • Path 的贝赛尔曲线

    • 手势监听

    • ValueAnimator 属性动画

    一、拉伸效果

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

    方法预览:

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

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

    red

    那么我们可以得出:

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

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

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

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

    red
    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、小圆点的拉伸效果

    先来看看拉伸的效果图:

    red

    这里就要讲解第二个知识点,Path 路径贝塞尔曲线,如果您对路径还不了解,请链接以下地址:

    自定义View之绘图篇(二):路径(Path)

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

    • 中心小圆

    • 跟手指移动的小圆

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

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

    red

    图片链接地址

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

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

    red

    为了实现手指触摸屏幕跟随手指移动的小圆效果,重写 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 以后的手指状态。

    自定义View系列教程06--详解View的Touch事件处理

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

    red

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

    从上效果图中分析可得:

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

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

    red

    根据示意图得到:

      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);//绘出该控件的所有子控件
        }
    

    相关 saveLayer , restore 的相关知识点请连接以下地址。

    自定义控件三部曲之绘图篇(十三)——Canvas与图层(一)

    自定义控件三部曲之绘图篇(十四)——Canvas与图层(二)

    我超崇拜的启航大神的博客。

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

    在 onTouchEvent 方法中调用的是 postInvalidate() 从新绘制,从新绘制有两个方法:postInvalidate ,invadite 。
    invadite 必须在 UI 线程中调用,而 postInvalidate 内部是由Handler的消息机制实现的,可以在任何线程中调用,效率没有 invadite 高 。

    拉伸范围内释放效果

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

    red
    • 初始位置只显示 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:

    <?xml version="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);
        }
    

    效果图:

    red

    这里我们需要注意 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();
        }
    

    效果图:

    red

    点击TextView拖动效果

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

        int x= (int) event.getX();
        int y= (int) event.getY();
    
         if(x>=mDragTextView.getLeft()&&x<=mDragTextView.getRight()&&y<=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();
        }
    

    啾啾效果图:

    red

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

    越拉伸,小球越小

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

    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);
        }
    

    看看效果:

    red

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

    手指释放,在 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;
        }
    

    有关属性动画的文章,请链接以下地址:

    自定义控件三部曲之动画篇(四)——ValueAnimator基本使用

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

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

    有关插值器的文章,请链接以下地址:

    自定义控件三部曲之动画篇(二)——Interpolator插值器

    通过 (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;
                }
            });
    

    拉伸范围外的效果

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

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

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

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

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

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

    最后预祝各位过个开心的 11、11

    源码地址

    相关文章

      网友评论

      本文标题:自定义View之案列篇(三):仿QQ小红点

      本文链接:https://www.haomeiwen.com/subject/oggkmttx.html