android自定义view——模仿qq消息气泡效果

先放一个效果,这是比平常气泡尺寸大很多的效果了。


android自定义view——模仿qq消息气泡效果_第1张图片
android自定义view——模仿qq消息气泡效果_第2张图片
最终效果

首先分析,这个view怎么分解成我们经常做的效果:

  • 静部分:两个圆,两条弧线
    • 圆就是两个完整的circle,这个好说
    • 弧线,我们发现,不是标准的椭圆弧了,这里一般使用二阶贝塞尔曲线(
      网上有很多讲的非常棒的文章,简单粗暴的说,就是PS里面,用两个锚点画曲线)
      图:
android自定义view——模仿qq消息气泡效果_第3张图片

贝塞尔曲线的绘制过程:
A —— 二阶曲线(quadTo)——B——一阶曲线(就是lineto)——C——二阶曲线(quadTo)——D——闭合
这里quadTo()里的前两个参数是控制点坐标,也就是图中的g点,后两个参数是结束点,图中B/D既是。

  • 动部分:弧线和一个圆的形状是变化的,这部分可以监听touch来刷新;
    回弹效果,自定义动画实现,
    最后气泡消失的爆炸效果,一般是使用帧动画,本例暂时没有写这个

好,思路基本捋清楚了。

创建一个View的子类

定义可以外部控制的变量
老规矩,attrs文件中:


        
        
        
        
    

java代码中:
先定义各种需要用到的变量和准确的注释

    /** 气泡颜色*/
    private int mColor;
    /** 中心文字颜色 */
    private int mTextColor;
    /** 中心文字内容 */
    private String mText;

    /** 原始气泡大小 */
    private float mBubbleRadius;
    /** 拖拽时跟随手指的气泡大小 */
    private float mDragBubbleRadius;

    /** 原始气泡的x、y坐标 */
    private float mBCx, mBCy;
    /** 拖拽气泡的x、y坐标 */
    private float mDBCx, mDBCy;
    /** 两圆圆心的距离 */
    private float defLength;
    /** 两圆圆心距离阀值,超过此值原始气泡消失 */
    private float defMaxLength;

    /*以下是贝塞尔曲线的相关点*/
    /** 原始气泡圆的起点x y */
    private float mStartX, mStartY;
    /** 原始气泡圆的终点 x y */
    private float mEndX, mEndY;
    /** 拖拽中的圆的起点 x y */
    private float mDStartX, mDStartY;
    /** 拖拽中的圆的终点 */
    private float mDEndX, mDEndY;
    /** 控制点的x y坐标 */
    private float mCtrlX, mCtrlY;

    /** 文字的尺寸 */
    private Rect mRect;

    /** 气泡的状态enum类 */
    enum BubbleState {
        /** 默认,无法拖拽 */  DEFAULT,
        /** 拖拽 */           DRAG,
        /** 移动 */           MOVE,
        /** 消失 */           DISMISS
    }
    /** 气泡的状态 */
    private BubbleState mState;

    private Paint mPaint;
    private Paint mTextPaint;//文字画笔
    private Path mBezierPath;//曲线的画笔

构造器

public MsgBubbleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(
                attrs, R.styleable.MsgBubbleView, 0, defStyleAttr);

        for (int i = 0; i < typedArray.length(); i++) {
            int attr = typedArray.getIndex(i);

            switch (attr) {
                case R.styleable.MsgBubbleView_centerText:
                    mText = typedArray.getString(attr);
                    break;
                case R.styleable.MsgBubbleView_textColor:
                    mTextColor = typedArray.getColor(attr, Color.WHITE);
                    break;
                case R.styleable.MsgBubbleView_color:
                    mColor = typedArray.getColor(attr, Color.RED);
                    break;
                case R.styleable.MsgBubbleView_bubbleRadius:
                    mBubbleRadius = typedArray.getDimensionPixelSize(attr, 
                            (int) TypedValue.applyDimension(
                            TypedValue.COMPLEX_UNIT_DIP, 20, getResources().getDisplayMetrics()));
                    break;
            }
        }

        mDragBubbleRadius = mBubbleRadius;
        defMaxLength = mBubbleRadius * 8;//两圆心距离最大阀值

        typedArray.recycle();

        mState = DEFAULT;

        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(mColor);
        mPaint.setTextSize(2);

        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setColor(mTextColor);
        mTextPaint.setTextSize(16);

        mBezierPath = new Path();
        mRect = new Rect();
    }

中间测量什么的可以先选择省略,直接进入onDraw():

            //画小圆
            canvas.drawCircle(mBCx, mBCy, mBubbleRadius, mPaint);
            //控制点在两圆心中点,起点终点位于圆上固定位置————圆上与两圆心连线垂直的过圆心的直线两端
            mCtrlX = (mBCx + mDBCx) / 2;
            mCtrlY = (mBCy + mDBCy) / 2;

            float tx = (mDBCy - mBCy) / defLength;
            float ty = (mDBCx - mBCx) / defLength;

            mStartX = mBCx - mBubbleRadius * tx;
            mStartY = mBCy + mBubbleRadius * ty;

            mDEndX = mDBCx - mDragBubbleRadius * tx;
            mDEndY = mDBCy + mDragBubbleRadius * ty;

            mDStartX = mDBCx + mDragBubbleRadius * tx;
            mDStartY = mDBCy - mDragBubbleRadius * ty;

            mEndX = mBCx + mBubbleRadius * tx;
            mEndY = mBCy - mBubbleRadius * ty;
            //画贝塞尔曲线
            mBezierPath.reset();
            mBezierPath.moveTo(mStartX, mStartY);
            mBezierPath.quadTo(mCtrlX, mCtrlY, mDEndX, mDEndY);
            mBezierPath.lineTo(mDStartX, mDStartY);
            mBezierPath.quadTo(mCtrlX, mCtrlY, mEndX, mEndY);
            mBezierPath.close();
            canvas.drawPath(mBezierPath, mPaint);

这里使用的是我看过的博客中最多的一种写法,将贝塞尔曲线的起点和终点固定在圆上,圆上与两圆心连线垂直的直径的两端点。。这里的原理很简单,纸上画一下就能得到几个关键点了。

这时候的效果如下图(当然自己写的话还要写上后面的状态判断和触摸监听什么的,我这里是先把这种效果放出来),那么和qq上效果一致了,但是放大一点看,就是下面的效果:弧线和圆这里衔接明显很不圆润。(qq效果图我没仔细放大看,但是看着也有点像是这种效果)

android自定义view——模仿qq消息气泡效果_第4张图片
控制点在两圆心中点,起点终点位于圆上固定位置————圆上与两圆心连线垂直的过圆心的直线两端
那么怎么画没有断层感呢?

有一个方法,就是过控制点作两个圆的切线了(声明:其实两种方法的效果小图看不出来太大的区别,不是强迫症可以不用看我要实现的这种方法,算起来真的头疼)。

还是看这一个图:


android自定义view——模仿qq消息气泡效果_第5张图片

g点是我们的控制点,过g点做两个圆的切线,这四条切线上的五个点就是两个贝塞尔曲线的关键点了。
当然,这个图当时设计的是g是两个圆心连线的中点,写到后面会出现一个bug,就是当两圆圆心距离小雨圆n的半径时,g点在圆n内,没有圆n的切线。所以本例后来就把g点的位置改为了两圆心相连的线段与两圆的交点的中点,有点绕,但是其实画画图就明白了。

这里的控制点(即中点)需要一点计算了,还是先画一个模型图,将问题转化为一个简单的数学题:

android自定义view——模仿qq消息气泡效果_第6张图片

思路如下:
线段 O1O2 = √ ((a- m )² + (b - n)²) = L ——————①
两个点之间长度 QK = L - R - r = L1 ——————②
所以点J分割线段 L 的比例为 (L1/2+r):(L1/2+R)
然后引入比例公式就把点J求出来了,我把这一步封装成了一个方法:

/**
     * 求两圆之间最短线段的中点
     * @param r_1st 第一个圆的半径
     * @param x_1st 第一个圆的x
     * @param y_1st 第一个圆的y
     * @param r_2nd 第二个圆半径
     * @param x_2nd 第二个圆的x
     * @param y_2nd 第二个圆的y
     * @return 中点坐标
     */
public static PointF getCtrlPoint(float r_1st , float x_1st , float y_1st, float r_2nd , float x_2nd , float y_2nd){
        PointF pointF = new PointF();

        float l = (float) ((Math.sqrt( Math.pow(x_1st - x_2nd , 2) + Math.pow(y_1st - y_2nd ,2) ) - r_2nd - r_1st) /2);
        float w = (l + r_1st) / (l + r_2nd);//比例
        //由以下公式直接拿到中点坐标:
        /*在直角坐标系内,已知两点P1(x1,y1),P2(x2,y2);在两点连线上有一点P,
        设它的坐标为(x,y),且线段P1P比线段PP2的比值为 λ ,那么可以求出P的坐标为
        x=(x1 + λ · x2) / (1 + λ)
        y=(y1 + λ · y2) / (1 + λ)*/
        pointF.x = (x_1st + w * x_2nd) / (1 + w);
        pointF.y = (y_1st + w * y_2nd) / (1 + w);
        return pointF;
    }

在代码中使用:

            //控制点
            PointF ctrl = CircleUtils.getCtrlPoint(mBubbleRadius,mBCx,mBCy,mDragBubbleRadius,mDBCx,mDBCy);
            mCtrlX = ctrl.x;
            mCtrlY = ctrl.y;

好了,现在我们有了控制点,有了两个圆的半径和圆心坐标,接下来就是求出四个切点了。还是转化为数学题:


android自定义view——模仿qq消息气泡效果_第7张图片

(图中坐标系可以去掉,这里坐标原点是圆心其实有点误导。)
思路很简单,五个常量,求切点坐标,我们很容易就能得到一个方程组,

  • (x-a)² + (y-b)² = r² ——————圆的方程
  • (n-y) / (m-x) *(y-b) / (x-a) = -1 ——————切线和与它垂直的直径斜率之积 = -1
    简单吧,接下来就是愉快的化简了。
    呵呵哒,我这里只说一点,后来我找了三个淘宝代做数学题的,都退款给我了。

最后的最后,找我的同事求救了,问她能不能用py解一下二元二次方程组,最后她推荐了我用matlab,挺好用:

解出来是这样的.

x =
 
 - (- a^2 + m*a - b^2 + n*b + r^2)/(a - m) - ((b - n)*(a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m + a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) - m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2)))/((a - m)*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2))
 - (- a^2 + m*a - b^2 + n*b + r^2)/(a - m) - ((b - n)*(a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m - a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) + m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2)))/((a - m)*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2))
 
 
y =
 
 (a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m + a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) - m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2))/(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2)
 (a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m - a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) + m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2))/(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2)

这个过程我们可以不看,根据算出来的解,封装成了一个方法:

/**
     * 已知半径的圆的圆心和圆外一点坐标,求该点到圆的两个切点
     *
     * @param r 半径
     * @param a 圆心x
     * @param b 圆心y
     * @param m 点x
     * @param n 点y
     * @return 两个切点
     */
    public static float[] getPointTangency(float r, float a, float b, float m, float n) {
        float[] points = new float[4];

        float m_pow_2 = (float) Math.pow(m, 2);
        float n_pow_2 = (float) Math.pow(n, 2);
        float r_pow_2 = (float) Math.pow(r, 2);
        float a_pow_2 = (float) Math.pow(a, 2);
        float b_pow_2 = (float) Math.pow(b, 2);
        float b_pow_3 = (float) Math.pow(b, 3);


        float sq = (float) java.lang.Math.sqrt(a_pow_2 - 2 * m * a + b_pow_2 - 2 * n * b + m_pow_2 + n_pow_2 - r_pow_2);

        float x1 = -(-a_pow_2 + m * a - b_pow_2 + n * b + r_pow_2) / (a - m)
                - ((b - n) * (a_pow_2 * b + b * m_pow_2 + b * n_pow_2
                - 2 * b_pow_2 * n - b * r_pow_2 + n * r_pow_2 + b_pow_3
                - 2 * a * b * m + a * r * sq - m * r * sq))
                / ((a - m) * (a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2));

        float x2 = -(-a_pow_2 + m * a - b_pow_2 + n * b + r_pow_2) / (a - m)
                - ((b - n) * (a_pow_2 * b + b * m_pow_2 + b * n_pow_2
                - 2 * b_pow_2 * n - b * r_pow_2 + n * r_pow_2 + b_pow_3
                - 2 * a * b * m - a * r * sq + m * r * sq))
                / ((a - m) * (a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2));

        float y1 = (a_pow_2 * b + b * m_pow_2 + b * n_pow_2 - 2 * b_pow_2 * n
                - b * r_pow_2 + n * r_pow_2 + b_pow_3
                - 2 * a * b * m + a * r * sq - m * r * sq) /
                (a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2);
        float y2 = (a_pow_2 * b + b * m_pow_2 + b * n_pow_2 - 2 * b_pow_2 * n
                - b * r_pow_2 + n * r_pow_2 + b_pow_3
                - 2 * a * b * m - a * r * sq + m * r * sq) /
                (a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2);


//        Log.d("MSL", "getPointTangency: " + x1 + "," + y1 + "\n" + x2 + "," + y2);
//        Log.d("MSL", "getPointTangency: " + sq + ", " + a_pow_2 + "," + b_pow_2 + "," + m_pow_2 + "," + n_pow_2 + ",," + b_pow_3);

        points[0] = x1;
        points[2] = y1;
        points[1] = x2;
        points[3] = y2;

        return points;
    }

然后代码中使用是这样的:

            float[] bubblePoints = CircleUtils.getPointTangency(mBubbleRadius, mBCx, mBCy, mCtrlX, mCtrlY);
            float[] dragBubbllePoints = CircleUtils.getPointTangency(mDragBubbleRadius, mDBCx, mDBCy, mCtrlX, mCtrlY);

            if ((mBCx < mDBCx && mBCy < mDBCy) || (mBCx > mDBCx && mBCy > mDBCy)){
                //drag相对原位置于第一/三象限
                mStartX = Math.min(bubblePoints[0],bubblePoints[1]);
                mStartY = Math.max(bubblePoints[2],bubblePoints[3]);

                mDEndX = Math.min(dragBubbllePoints[0],dragBubbllePoints[1]);
                mDEndY = Math.max(dragBubbllePoints[2],dragBubbllePoints[3]);

                mDStartX = Math.max(dragBubbllePoints[0],dragBubbllePoints[1]);
                mDStartY = Math.min(dragBubbllePoints[2],dragBubbllePoints[3]);

                mEndX = Math.max(bubblePoints[0],bubblePoints[1]);
                mEndY = Math.min(bubblePoints[2],bubblePoints[3]);

            }else if ((mBCx > mDBCx && mBCy < mDBCy) || (mBCx < mDBCx && mBCy > mDBCy)){
                //drag相对原位置于第二/四象限
                mStartX = Math.min(bubblePoints[0],bubblePoints[1]);
                mStartY = Math.min(bubblePoints[2],bubblePoints[3]);

                mDEndX = Math.min(dragBubbllePoints[0],dragBubbllePoints[1]);
                mDEndY = Math.min(dragBubbllePoints[2],dragBubbllePoints[3]);

                mDStartX = Math.max(dragBubbllePoints[0],dragBubbllePoints[1]);
                mDStartY = Math.max(dragBubbllePoints[2],dragBubbllePoints[3]);

                mEndX = Math.max(bubblePoints[0],bubblePoints[1]);
                mEndY = Math.max(bubblePoints[2],bubblePoints[3]);
            }

其实写到这里我就后悔了,优化了一点小功能,最后掉了不知道多少根头发!!

必须对比一下!
android自定义view——模仿qq消息气泡效果_第8张图片

onDraw()里面就差不多了,绘制文字什么的就先不贴上来了。

重写onTouchEvent():

@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (mState == DISMISS) break;
                //request父控件不拦截点击事件
                getParent().requestDisallowInterceptTouchEvent(true);

                defLength = (float) Math.hypot(event.getX() - mDBCx, event.getY() - mDBCy);
                if (defLength < mDragBubbleRadius + defMaxLength / 4) {
                    mState = DRAG;
                } else {
                    mState = DEFAULT;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                if (mState == DEFAULT) break;
                getParent().requestDisallowInterceptTouchEvent(true);
                mDBCx = event.getX();
                mDBCy = event.getY();
                defLength = (float) Math.hypot(mDBCx - mBCx, mDBCy - mBCy);
                if (mState == DRAG) {
                    if (defLength < defMaxLength - defMaxLength / 4) {//
                        mBubbleRadius = mDragBubbleRadius - defLength / 8;//小球逐渐变小,直至逐渐消失
                        if (mBubbleStateListener != null) {
                            mBubbleStateListener.onDrag();
                        }
                    } else {//间距大于最大拖拽距离之后
                        mState = MOVE;//开始拖动
                        if (mBubbleStateListener != null) {
                            mBubbleStateListener.onMove();
                        }
                    }
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                getParent().requestDisallowInterceptTouchEvent(false);

                //拖拽过程中未移动之前(小气泡没有消失之前)松手,气泡回到原来位置,并颤动一下
                if (mState == DRAG) {
                    setBubbleResetAnimation();
                } else if (mState == MOVE) {
                    if (defLength < 2 * mDragBubbleRadius) {
                        //松手的位置距离在气泡原始位置周围,说明user不想取消这个提示,让气泡回到原来位置并颤动
                        setBubbleResetAnimation();
                    } else {
                        //取消气泡显示,并显示dismiss的动画
                    }
                }
                break;
        }
        return true;
    }

取消移动时的动画效果:

private void setBubbleResetAnimation() {
        ValueAnimator valueAnimator = ValueAnimator.ofObject(new PointFEvaluator(),
                new PointF(mDBCx, mDBCy), new PointF(mBCx, mBCy));
        valueAnimator.setDuration(500);

        valueAnimator.setInterpolator(new TimeInterpolator() {
            @Override
            public float getInterpolation(float input) {

                float f = 0.4234219f;
                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 c = (PointF) animation.getAnimatedValue();
                mDBCx = c.x;
                mDBCy = c.y;
                invalidate();
            }
        });
        valueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mState = DEFAULT;
                if (mBubbleStateListener == null) return;
                mBubbleStateListener.onReset();
            }
        });
        valueAnimator.start();
    }

其实这个demo还没写完,气泡dismiss的动画没有写,这个是因为懒得找爆炸的桢图了(其实是被数学虐惨了 - -!)

封装的求切点的方法其实还是有点bug的,有时候计算会出现not a number的异常,应该是负数开方导致的,具体这点跪求知情者帮忙解惑!感谢~~

最后 源码点击查看

你可能感兴趣的:(android自定义view——模仿qq消息气泡效果)