仿 QQ 未读消息气泡,可拖拽删除,粘连效果。

2,683 阅读7分钟
原文链接: www.jianshu.com

效果图.gif

使用方法

依赖

compile 'com.szd:messagebubble:1.0.1'

注意:使用时需要在父布局中加入android:clipChildren="false"属性,使气泡在可以在父布局中拖动。如一个界面中由一个RelativeLayout中包含一个Recyclerview组成,则需要在Recyclerview的Item布局中、Recyclerview、RelativeLayout中都加入该属性。

可选用的属性有:

属性 作用
app:radius 圆的半径
app:circleColor 圆的颜色
app:textSize 未读消息的大小
app:number 未读消息的数量
app:textSize 未读消息的大小

代码中提供可调用的方法:
setDisappearPic(): 接受一组int类型的数组。可将需要自定义的消失动画放入数组中传入。
setNumber(): 设置需要显示的未读消息数量。
setOnActionListener(): 操作的监听,其中包括:

  • onDrag():被拖拽时,且未超出最大可拖拽距离。
  • onMove():被拖拽时,已超出最大可拖拽距离。
  • onDisappear: 被拖拽的圆消失后。
  • onRestore: 被拖拽后又回到原点。

实现思路

首先我们需要两个圆,一个是在原点不需要跟随手指的圆,一个是跟随手指的圆,当用户开始点击时,绘制跟随手指的圆和圆上的未读消息数量,同时在手指移动时,不停地判断两圆之间的距离是否超过我们所设定的最远距离,如果未超过这个距离,则在两圆之间,以两圆圆心的中间点为控制点绘制贝塞尔曲线,如果超过距离,则停止绘制贝塞尔曲线,两圆成独立状态移动。用户松开手指时,同样对两圆之间的距离进行判断,如在最远距离内,被拖动的圆自行回到原点,如超过最远距离,则在手指释放位置播放删除动画。

1.初始化

有了思路后首先我们要确定有可能使用者会需要自定义的参数,目前我设定了以下5个自定义参数,具体含义可见使用方法中的表格。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MessageBubble">
        <attr name="radius" format="dimension" />
        <attr name="textSize" format="dimension" />
        <attr name="circleColor" format="color" />
        <attr name="textColor" format="color" />
        <attr name="number" format="string"/>
    </declare-styleable>
</resources>

之后做一些准备工作,例如我们会需要一个Path来绘制贝塞尔曲线,不同的画笔来绘制圆、数字、消失动画,定义初始圆的x,y坐标等。

2.确定View的大小

这里如果使用wrap_content属性的话,我们为这个view设定400px*400px的大小,

    @Override
    protected void onMeasure(int widthMeasure, int heightMeasure) {
        int widthMode = MeasureSpec.getMode(widthMeasure);
        int widthSize = MeasureSpec.getSize(widthMeasure);
        int heightMode = MeasureSpec.getMode(heightMeasure);
        int heightSize = MeasureSpec.getSize(heightMeasure);

        if (widthMode == MeasureSpec.EXACTLY) {
            mWidth = widthSize;
        } else {
            mWidth = getPaddingLeft() + 400 + getPaddingRight();
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            mHeight = heightSize;
        } else {
            mHeight = getPaddingTop() + 400 + getPaddingBottom();
        }
        setMeasuredDimension(mWidth, mHeight);
    }

3.重写onTouchEvent()

首先,我们将View的状态分为了4中,分别是普通状态、拖动状态、移动状态、消失状态。
当View接收到手指按下也就是ACTION_DOWN事件时,首先判断手指点击的位置是否处于原始圆的范围内,这里我们通过event得到手指点击的x,y坐标,计算出手指点击位置与圆心的距离,如果距离小于圆的半径,则可以证明手指点击在圆的范围内。同时应当将这个范围适当调整,以满足小尺寸手机。

当手指按下位置在圆的范围内,且开始拖动时,View开始消费ACTION_MOVE的事件,同时被设定为STATE_DRAGING状态。首先我们也要得到手指点击位置的x,y坐标,用来绘制被拖动圆的圆心。在拖动的同时,我们要去判断当前拖动的距离是否超出最大的可拖拽的距离,如果未超过,在绘制被拖动圆的同时要绘制两圆间的粘连效果,如果超过最大可拖拽距离,则View被设定为STATE_MOVE状态,不再绘制粘连效果,被拖拽圆独立绘制。

当手指放开时,View消费ACTION_UP事件。此时我们首先要判断手指放开时,被拖拽圆与原始圆两圆圆心的距离,如果超出最大范围,则状态改为STATE_DISAPPEAR,同时播放气泡的消失动画,如果在最大范围内,则状态改为STATE_RESTORE,同时播放气泡复原的动画。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                if (curState != STATE_DISAPPEAR) {
                    //计算点击位置与气泡的距离
                    d = (float) Math.hypot(centerCircleX - event.getX()
                    , centerCircleY - event.getY());
                    if (d < centerRadius + 10) {
                        curState = STATE_DRAGING;
                    } else {
                        curState = STATE_NORMAL;
                    }
                }
                break;
            case MotionEvent.ACTION_MOVE:
                dragCircleX = (int) event.getX();
                dragCircleY = (int) event.getY();
                //拖拽状态下计算拖拽距离,超出後不再計算
                if (curState == STATE_DRAGING) {
                    d = (float) Math.hypot(centerCircleX - event.getX()
                    , centerCircleY - event.getY());
                    if (d <= maxDragLength - maxDragLength / 7) {
                        centerRadius = dragRadius - d / 4;
                        if (actionListener != null) {
                            actionListener.onDrag();
                        }
                    } else {
                        centerRadius = 0;
                        curState = STATE_MOVE;
                    }
                  //超出最大拖拽距离,则中间的圆消失
                } else if (curState == STATE_MOVE) {
                    if (actionListener != null) {
                        actionListener.onMove();
                    }
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                getParent().requestDisallowInterceptTouchEvent(false);
                //当正在拖动时,抬起手指才会做响应的处理
                if (curState == STATE_DRAGING || curState == STATE_MOVE) {
                    d = (float) Math.hypot(centerCircleX - event.getX()
                    , centerCircleY - event.getY());
                    if (d > maxDragLength) {//如果拖拽距离大于最大可拖拽距离,则消失
                        curState = STATE_DISAPPEAR;
                        startDisappear = true;
                        disappearAnim();
                    } else {//小于可拖拽距离,则复原气泡位置
                        restoreAnim();
                    }
                    invalidate();
                }
                break;
        }
        return true;
    }

4.绘制

如onTouchEvent()中所说,我们将View分为了4中不同的状态,所以在绘制时,我们只需要根据不同的状态进行绘制即可。

    @Override
    protected void onDraw(Canvas canvas) {
        if (curState == STATE_NORMAL) {
            //画初始圆
            canvas.drawCircle(centerCircleX, centerCircleY, centerRadius, mPaint);
            //画数字(要在画完贝塞尔曲线之后绘制,不然会被挡住)
            canvas.drawText(mNumber, centerCircleX, centerCircleY + textMove, textPaint);
        }
        //如果开始拖拽,则画dragCircle
        if (curState == STATE_DRAGING) {
            //画初始圆
            canvas.drawCircle(centerCircleX, centerCircleY, centerRadius, mPaint);
            //画被拖拽的圆
            canvas.drawCircle(dragCircleX, dragCircleY, dragRadius, mPaint);
            drawBezier(canvas);
            canvas.drawText(mNumber, dragCircleX, dragCircleY + textMove, textPaint);
        }

        if (curState == STATE_MOVE) {
            canvas.drawCircle(dragCircleX, dragCircleY, dragRadius, mPaint);
            canvas.drawText(mNumber, dragCircleX, dragCircleY + textMove, textPaint);
        }

        if (curState == STATE_DISAPPEAR && startDisappear) {
            if (disappearBitmap != null) {
                canvas.drawBitmap(disappearBitmap[bitmapIndex], null, bitmapRect, disappearPaint);
            }
        }

    }

其中绘制两圆间粘连效果的贝塞尔曲线我们需要去计算绘制的起点、控制点和中心点。这里我是参照这篇博文写的,因为初中数学早都忘干净了,实在算不出来。

高仿QQ未读消息气泡拖拽黏连效果

如果有朋友想自己写写看,也可以参照这张非常有用的图,当然我看不懂,所以我就不讲了,直接贴上代码。


贝塞尔曲线控制点计算.jpg
/**
     * 绘制贝塞尔曲线
     * @param canvas canvas
     */
    private void drawBezier(Canvas canvas) {
        float controlX = (centerCircleX + dragCircleX) / 2;//贝塞尔曲线控制点X坐标
        float controlY = (dragCircleY + centerCircleY) / 2;//贝塞尔曲线控制点Y坐标
        //计算曲线的起点终点
        d = (float) Math.hypot(centerCircleX - dragCircleX, centerCircleY - dragCircleY);
        float sin = (centerCircleY - dragCircleY) / d;
        float cos = (centerCircleX - dragCircleX) / d;
        float dragCircleStartX = dragCircleX - dragRadius * sin;
        float dragCircleStartY = dragCircleY + dragRadius * cos;
        float centerCircleEndX = centerCircleX - centerRadius * sin;
        float centerCircleEndY = centerCircleY + centerRadius * cos;
        float centerCircleStartX = centerCircleX + centerRadius * sin;
        float centerCircleStartY = centerCircleY - centerRadius * cos;
        float dragCircleEndX = dragCircleX + dragRadius * sin;
        float dragCircleEndY = dragCircleY - dragRadius * cos;

        mPath.reset();
        mPath.moveTo(centerCircleStartX, centerCircleStartY);
        mPath.quadTo(controlX, controlY, dragCircleEndX, dragCircleEndY);
        mPath.lineTo(dragCircleStartX, dragCircleStartY);
        mPath.quadTo(controlX, controlY, centerCircleEndX, centerCircleEndY);
        mPath.close();

        canvas.drawPath(mPath, mPaint);
    }

气泡消失动画:
消失动画的图片可以调用setDisappearPic()方法进行修改,同时也有默认动画。由于动画采用类似帧动画的原理,所以需要传入一个保存了动画图片的int类型数组,默认动画500ms,如果有需要可以新增设置动画时长的方法。

我们使用属性动画,从0开始到消失动画图片的张数结束,取出动画的当前进度作为下标,并通知View重新绘制,View重新绘制时,读取当前的下标值,并从数组中取出图片进行绘制。

    /**
     * 气泡消失动画
     */
    private void disappearAnim() {
        bitmapRect = new Rect(dragCircleX - (int) dragRadius , dragCircleY - (int) dragRadius     ,dragCircleX + (int) dragRadius, dragCircleY + (int) dragRadius);
        ValueAnimator disappearAnimator = ValueAnimator.ofInt(0, disappearBitmap.length);
        disappearAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                bitmapIndex = (int) animation.getAnimatedValue();
                invalidate();
            }
        });
        disappearAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                startDisappear = false;
                if (actionListener != null) {
                    actionListener.onDisappear();
                }
            }
        });
        disappearAnimator.setInterpolator(new LinearInterpolator());
        disappearAnimator.setDuration(500);
        disappearAnimator.start();
    }

气泡复原动画:

/**
     * 气泡复原动画
     */
    private void restoreAnim() {
        ValueAnimator valueAnimator = ValueAnimator.ofObject(new MyPointFEvaluator(), new PointF(dragCircleX, dragCircleY), new PointF(centerCircleX, centerCircleY));
        valueAnimator.setDuration(200);
        valueAnimator.setInterpolator(new TimeInterpolator() {
            @Override
            public float getInterpolation(float input) {
                float f = 0.571429f;
                return (float) (Math.pow(2, -4 * input) * Math.sin((input - f / 4) * (2 * Math.PI) / f) + 1);
            }
        });
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                PointF pointF = (PointF) animation.getAnimatedValue();
                dragCircleX = (int) pointF.x;
                dragCircleY = (int) pointF.y;
                invalidate();
            }
        });
        valueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //复原了
                centerRadius = dragRadius;
                curState = STATE_NORMAL;
                if (actionListener != null) {
                    actionListener.onRestore();
                }
            }
        });
        valueAnimator.start();
    }
     /**
     * PointF动画估值器(复原时的振动动画)
     */
    private class MyPointFEvaluator implements TypeEvaluator<PointF> {

        @Override
        public PointF evaluate(float fraction, PointF startPointF, PointF endPointF) {
            float x = startPointF.x + fraction * (endPointF.x - startPointF.x);
            float y = startPointF.y + fraction * (endPointF.y - startPointF.y);
            return new PointF(x, y);
        }
    }

本篇博客中,气泡复原动画、消失动画的图片素材以及贝塞尔曲线绘制的部分参考或借鉴了 《高仿QQ未读消息气泡拖拽黏连效果》如有侵权会立即删除并停止使用。


github地址:github.com/icetea0822/…