Android自定义View实现QQ气泡效果

首先我们来看一下最终的效果:

根据我们上边拆分出来的公式,我们分别看看每一个效果需要如何去实现:

红色圆:canvas.drawCircle

消息数字:canvas.drawText

拖拽粘性效果:canvas.drawPath、 (两条二阶)贝塞尔曲线  (精髓所在)

回弹效果:属性动画

跟随移动:OnTouchEvent处理MotionEvent.ACTION_MOVE事件

爆炸效果:属性动画

View自定义属性

为了提高自定义View的灵活性,我们需要提供几种自定义属性给外部来设置,有如下属性:

气泡半径:bubble_radius

气泡颜色:bubble_color

气泡消息数字:bubble_text

气泡消息数字字体大小:bubble_textSize

气泡消息数字颜色:bubble_textColor

 

属性定义

在res -> values下添加如下attrs.xml文件:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="DragBubbleView">
        <attr name="bubble_radius" format="dimension"/>
        <attr name="bubble_color" format="color"/>
        <attr name="bubble_text" format="string"/>
        <attr name="bubble_textSize" format="dimension"/>
        <attr name="bubble_textColor" format="color"/>
    </declare-styleable>
</resources>

 在初始化方法中获取这些属性即可,下面完整代码会列出了。

初始化两个圆的圆心坐标

我们需要在View的size确定后初始化圆心坐标,所以需要在onSizeChanged中进行初始化。

 @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // 设置两个圆心初始位置在View中心位置,也可以通过自定义属性将此属性由外部自由设置
        //初始化不动气泡的圆心
        if (mBubMoveableCenter == null){
            mBubMoveableCenter = new PointF(w / 2f, h / 2f);
        } else {
            mBubMoveableCenter.set(w / 2f, h / 2f);
        }

        //初始化可动气泡的圆心
        if (mBubStillCenter == null){
            mBubStillCenter = new PointF(w / 2f, h / 2f);
        } else {
            mBubStillCenter.set(w / 2f, h / 2f);
        }
    }

定义气泡状态

气泡总共可以分为四个状态:

静止

相连(粘性拖拽、回弹状态)

分离(跟随触摸运动状态)

消失(爆炸状态)

分别对应下面四种状态

静止状态,一个气泡 + 消息数

连接状态,一个气泡 + 消息数 + 贝塞尔曲线 + 原本位置上的气泡(变化的)

分离状态,一个气泡 + 消息数

消失状态,爆炸效果

这些效果主要是在 onDraw 和  onTouchEvent 实现的,在绘制的过程涉及到了贝塞尔曲线,下面分析下实现的思路和一些细节:

我们需要做的,就是 AB CD 2 条二阶贝塞尔曲线。而 ABCD 这个不规则多边形,就是 气泡 ,根 据贝塞尔曲线的定义,可以发现, O 点、 P 点为已知点, G 点作为 AB CD 2 条贝塞尔曲线的控制点, A B C D 分别是 AB CD 贝塞尔曲线的数据点。因此求出 A B C D G5 个点的坐标就可以画出 2 条贝塞尔曲线了!
 
关于坐标的求解,一个个来:
 
G :坐标很简单,直接 O 点与 P 点的 x,y 相加除以 2 就可以算出来。
ABCD 点,根据高中的数学相关知识:
 
PE = O y 坐标 -P y 坐标
OE = P x 坐标 -O x 坐标
 
sin POE = PE / OP
cos POE = OE / OP
 
A 坐标:
x = O x 坐标 - sin POE * 固定圆半径
y = O y 坐标 - cos POE * 固定圆半径
 
B 坐标:
x = P x 坐标 - sin POE * 动圆半径
y = P y 坐标 - cos POE * 动圆半径
 
C 坐标:
x = P x 坐标 + sin POE * 动圆半径
y = P y 坐标 + cos POE * 动圆半径
 
D 坐标:
x = O x 坐标 + sin POE * 固定圆半径
y = O y 坐标 + cos POE * 固定圆半径
 
 //        一定要分状态 文字
        if (mBubbleState == BUBBLE_STATE_CONNECT) {
            canvas.drawCircle(mBubStillCenter.x, mBubStillCenter.y, mBubStillRadius, mBubblePaint);

            // cos    +
            float cosThrta = (mBubMoveableCenter.x - mBubStillCenter.x) / mDist;
            //  sin   +
            float sinTheta = (mBubMoveableCenter.y - mBubStillCenter.y) / mDist;

            // A
            float iBubStillStartX = mBubStillCenter.x - mBubStillRadius * sinTheta;
            float iBubStillStartY = mBubStillCenter.y + mBubStillRadius * cosThrta;

            // B
            float iBubMoveableEndX = mBubMoveableCenter.x - mBubbleRadius * sinTheta;
            float iBubMoveableEndY = mBubMoveableCenter.y + mBubMoveableRadius * cosThrta;
            //C
            float iBubMoveableStartX = mBubMoveableCenter.x + mBubMoveableRadius * sinTheta;
            float iBubMoveableStartY = mBubMoveableCenter.y - mBubMoveableRadius * cosThrta;

            //D
            float iBubStillEndX = mBubStillCenter.x + mBubStillRadius * sinTheta;
            float iBubStillEndY = mBubStillCenter.y - mBubStillRadius * cosThrta;

            //  G计算控制点坐标,两个圆心的中点
            int iAnchorX = (int) ((mBubStillCenter.x + mBubMoveableCenter.x) / 2);
            int iAnchorY = (int) ((mBubStillCenter.y + mBubMoveableCenter.y) / 2);

            mBezierPath.reset();
            //  移动到B点
            // 画上半弧
            mBezierPath.moveTo(iBubStillStartX, iBubStillStartY);
            mBezierPath.quadTo(iAnchorX, iAnchorY, iBubMoveableEndX, iBubMoveableEndY);
            // 画下半弧
            mBezierPath.lineTo(iBubMoveableStartX, iBubMoveableStartY);
            mBezierPath.quadTo(iAnchorX, iAnchorY, iBubStillEndX, iBubStillEndY);
            mBezierPath.close();
            canvas.drawPath(mBezierPath, mBubblePaint);
        }

设置触摸事件

当手指按下时,开始拖拽,气泡状态变为连接状态;
当手指移动时,处理粘性拖拽(连接状态)和跟随(分离状态);
当手指松开是,处理爆炸效果(消失状态)和回弹(回到静止状态);

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                // 非消失状态
                if (mBubbleState != BUBBLE_STATE_DISMISS) {
                    // 计算两个圆心的距离,当前触摸位置即为可动圆的圆心
                    mDist = (float) Math.hypot(event.getX() - mBubStillCenter.x, event.getY() - mBubStillCenter.y);
                    // 为了方便进行拖拽,增大拖拽识别范围
                    if (mDist < mBubbleRadius) {
                        // 更改为连接状态
                        mBubbleState = BUBBLE_STATE_CONNECT;
                    } else {
                        // 重置为默认状态
                        mBubbleState = BUBBLE_STATE_DEFAUL;
                    }
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                // 非静止状态
                if (mBubbleState != BUBBLE_STATE_DEFAUL){
                    // 计算两个圆心的距离,当前触摸位置即为可动圆的圆心
                    mDist = (float) Math.hypot(event.getX() - mBubStillCenter.x, event.getY() - mBubStillCenter.y);
                    //修改可动圆的圆心为触摸点
                    mBubMoveableCenter.x = event.getX();
                    mBubMoveableCenter.y = event.getY();
                    // 连接状态
                    if (mBubbleState == BUBBLE_STATE_CONNECT){
                        if (mDist < mMaxDist){
                            //当拖拽距离在指定范围内,调整不动圆半径
                            mBubStillRadius = mBubbleRadius - mDist / 8;
                        } else {
                            //超过指定范围,分离状态
                            mBubbleState = BUBBLE_STATE_APART;
                        }
                    }
                    // 重绘
                    invalidate();
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                // 连接状态下松开
                if (mBubbleState == BUBBLE_STATE_CONNECT) {
                    // 回弹效果
                    startBubbleRestAnim();
                } else if (mBubbleState == BUBBLE_STATE_APART){
                    // 分离状态下松开
                    if (mDist < 2 * mBubbleRadius){
                        // 距离较近时,回弹,不爆炸
                        startBubbleRestAnim();
                    } else {
                        // 爆炸效果
                        startBubbleBurstAnim();
                    }
                }
                break;
            }
        }
        return true;
    }

自定义控件完整类的代码如下:

package com.xifei.mydragbubbleview;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.PointFEvaluator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.Rect;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;

public class DragBubbleView extends View {

    private final int BUBBLE_STATE_DEFAUL = 0;

    //是否在执行气泡爆炸动画
    private boolean mIsBurstAnimStart = false;
    //气泡相连
    private final int BUBBLE_STATE_CONNECT = 1;
    //气泡分离
    private final int BUBBLE_STATE_APART = 2;
    //气泡消失
    private final int BUBBLE_STATE_DISMISS = 3;
    private int mBubbleState = BUBBLE_STATE_DEFAUL;
    //文字
    private Paint mTextPaint;
    //气泡画笔
    private Paint mBubblePaint;
    //气泡半径
    private float mBubbleRadius;
    //气泡消息文字
    private String mTextStr;
    //气泡消息文字颜色
    private int mTextColor;
    //气泡消息文字大小
    private float mTextSize;
    //气泡颜色
    private int mBubbleColor;
    //不动气泡的圆心
    private PointF mBubStillCenter;
    //可动气泡的圆心
    private PointF mBubMoveableCenter;

    //文本绘制区域
    private Rect mTextRect;
    //两气泡圆心的距离
    private float mDist;
    //可动气泡的半径
    private float mBubMoveableRadius;
    //贝塞尔曲线path
    private Path mBezierPath;
    //气泡相连状态最大圆心的距离
    private float mMaxDist;
    //不动气泡的半径
    private float mBubStillRadius;
    //气泡爆炸的图片id数组
    private int[] mBurstDrawablesArray = {R.drawable.burst_1, R.drawable.burst_2
            , R.drawable.burst_3, R.drawable.burst_4, R.drawable.burst_5};

    //气泡爆炸的bitmap数组
    private Bitmap[] mBurstBitmapsArray;
    //爆炸绘制区域
    private Rect mBurstRect;
    //当前气泡爆炸图片index
    private int mCurDrawableIndex;

    public DragBubbleView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs);
    }

    public DragBubbleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    private void init(Context context, AttributeSet attrs) {
        // 获取自定义属性数组
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.DragBubbleView);

        mBubbleRadius = array.getDimension(R.styleable.DragBubbleView_bubble_radius, mBubbleRadius);
        mBubbleColor = array.getColor(R.styleable.DragBubbleView_bubble_color, Color.RED);
        mTextStr = array.getString(R.styleable.DragBubbleView_bubble_text);
        mTextSize = array.getDimension(R.styleable.DragBubbleView_bubble_textSize, mTextSize);
        mTextColor = array.getColor(R.styleable.DragBubbleView_bubble_textColor, Color.WHITE);
        array.recycle();
        //文本画笔
        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setColor(Color.WHITE);
        //        textSize
        mTextPaint.setTextSize(mTextSize);
        mTextPaint.setColor(mTextColor);
        //抗锯齿  气泡画笔
        mBubblePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mBubblePaint.setColor(mBubbleColor);
        mBubblePaint.setStyle(Paint.Style.FILL);
        mTextRect = new Rect();
        mBubMoveableRadius = mBubbleRadius;
        mBubStillRadius = mBubbleRadius;
        mBezierPath = new Path();
        mMaxDist = 8 * mBubbleRadius;
        mBurstRect = new Rect();

        mBurstBitmapsArray = new Bitmap[mBurstDrawablesArray.length];
        for (int i = 0; i < mBurstDrawablesArray.length; i++) {
            //将气泡爆炸的drawable转为bitmap
            Bitmap bitmap = BitmapFactory.decodeResource(getResources(), mBurstDrawablesArray[i]);
            mBurstBitmapsArray[i] = bitmap;
        }
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // 设置两个圆心初始位置在View中心位置,也可以通过自定义属性将此属性由外部自由设置
        //初始化不动气泡的圆心
        if (mBubMoveableCenter == null){
            mBubMoveableCenter = new PointF(w / 2f, h / 2f);
        } else {
            mBubMoveableCenter.set(w / 2f, h / 2f);
        }

        //初始化可动气泡的圆心
        if (mBubStillCenter == null){
            mBubStillCenter = new PointF(w / 2f, h / 2f);
        } else {
            mBubStillCenter.set(w / 2f, h / 2f);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        //        一定要分状态 文字
        if (mBubbleState == BUBBLE_STATE_CONNECT) {
            canvas.drawCircle(mBubStillCenter.x, mBubStillCenter.y, mBubStillRadius, mBubblePaint);

            // cos    +
            float cosThrta = (mBubMoveableCenter.x - mBubStillCenter.x) / mDist;
            //  sin   +
            float sinTheta = (mBubMoveableCenter.y - mBubStillCenter.y) / mDist;

            // A
            float iBubStillStartX = mBubStillCenter.x - mBubStillRadius * sinTheta;
            float iBubStillStartY = mBubStillCenter.y + mBubStillRadius * cosThrta;

            // B
            float iBubMoveableEndX = mBubMoveableCenter.x - mBubbleRadius * sinTheta;
            float iBubMoveableEndY = mBubMoveableCenter.y + mBubMoveableRadius * cosThrta;
            //C
            float iBubMoveableStartX = mBubMoveableCenter.x + mBubMoveableRadius * sinTheta;
            float iBubMoveableStartY = mBubMoveableCenter.y - mBubMoveableRadius * cosThrta;

            //D
            float iBubStillEndX = mBubStillCenter.x + mBubStillRadius * sinTheta;
            float iBubStillEndY = mBubStillCenter.y - mBubStillRadius * cosThrta;

            //  G计算控制点坐标,两个圆心的中点
            int iAnchorX = (int) ((mBubStillCenter.x + mBubMoveableCenter.x) / 2);
            int iAnchorY = (int) ((mBubStillCenter.y + mBubMoveableCenter.y) / 2);

            mBezierPath.reset();
            //  移动到B点
            // 画上半弧
            mBezierPath.moveTo(iBubStillStartX, iBubStillStartY);
            mBezierPath.quadTo(iAnchorX, iAnchorY, iBubMoveableEndX, iBubMoveableEndY);
            // 画下半弧
            mBezierPath.lineTo(iBubMoveableStartX, iBubMoveableStartY);
            mBezierPath.quadTo(iAnchorX, iAnchorY, iBubStillEndX, iBubStillEndY);
            mBezierPath.close();
            canvas.drawPath(mBezierPath, mBubblePaint);
        }

        if (mBubbleState != BUBBLE_STATE_DISMISS) {
            // 绘制一个大小不变的气泡(可动气泡)
            canvas.drawCircle(mBubMoveableCenter.x, mBubMoveableCenter.y, mBubbleRadius, mBubblePaint);
            // 测量消息数的文本,并将测量数据保存在mTextRect中
            mTextPaint.getTextBounds(mTextStr, 0, mTextStr.length(), mTextRect);
            // 绘制文本在可动气泡的中心(参数位置是绘制区域的左下角的坐标)
            canvas.drawText(mTextStr, mBubMoveableCenter.x - mTextRect.width() / 2f,
                    mBubMoveableCenter.y + mTextRect.height() / 2f, mTextPaint);
        } else if (mCurDrawableIndex < mBurstBitmapsArray.length) {
            //爆炸状态
            //onDraw方法中
            mBurstRect.set((int) (mBubMoveableCenter.x - mBubMoveableRadius),
                    (int) (mBubMoveableCenter.y - mBubMoveableRadius),
                    (int) (mBubMoveableCenter.x + mBubMoveableRadius),
                    (int) (mBubMoveableCenter.y + mBubMoveableRadius));
            canvas.drawBitmap(mBurstBitmapsArray[mCurDrawableIndex], null,
                    mBurstRect, mBubblePaint);
        }

        //  A
        mTextPaint.getTextBounds(mTextStr,
                0, mTextStr.length(), mTextRect);
        canvas.drawText(mTextStr,
                mBubMoveableCenter.x - mTextRect.width() / 2,
                mBubMoveableCenter.y + mTextRect.height() / 2,
                mTextPaint);
    }

    private void startBubbleRestAnim() {
        ValueAnimator anim = ValueAnimator.ofObject(new PointFEvaluator(),
                new PointF(mBubMoveableCenter.x, mBubMoveableCenter.y),
                new PointF(mBubStillCenter.x, mBubStillCenter.y));
        anim.setDuration(400);
        //  反向执行  加速回来
        anim.setInterpolator(new OvershootInterpolator(5f));
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mBubMoveableCenter = (PointF) animation.getAnimatedValue();
                invalidate();
            }
        });
        anim.start();
    }

    private void startBubbleBurstAnim() {
        //气泡改为消失状态
        mBubbleState = BUBBLE_STATE_DISMISS;
        mIsBurstAnimStart = true;
        //做一个int型属性动画,从0~mBurstDrawablesArray.length结束
        ValueAnimator anim = ValueAnimator.ofInt(0, mBurstDrawablesArray.length);
        anim.setInterpolator(new LinearInterpolator());
        anim.setDuration(500);
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //设置当前绘制的爆炸图片index
                mCurDrawableIndex = (int) animation.getAnimatedValue();
                invalidate();
            }
        });
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //修改动画执行标志
                mIsBurstAnimStart = false;
            }
        });
        anim.start();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                // 非消失状态
                if (mBubbleState != BUBBLE_STATE_DISMISS) {
                    // 计算两个圆心的距离,当前触摸位置即为可动圆的圆心
                    mDist = (float) Math.hypot(event.getX() - mBubStillCenter.x, event.getY() - mBubStillCenter.y);
                    // 为了方便进行拖拽,增大拖拽识别范围
                    if (mDist < mBubbleRadius) {
                        // 更改为连接状态
                        mBubbleState = BUBBLE_STATE_CONNECT;
                    } else {
                        // 重置为默认状态
                        mBubbleState = BUBBLE_STATE_DEFAUL;
                    }
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                // 非静止状态
                if (mBubbleState != BUBBLE_STATE_DEFAUL){
                    // 计算两个圆心的距离,当前触摸位置即为可动圆的圆心
                    mDist = (float) Math.hypot(event.getX() - mBubStillCenter.x, event.getY() - mBubStillCenter.y);
                    //修改可动圆的圆心为触摸点
                    mBubMoveableCenter.x = event.getX();
                    mBubMoveableCenter.y = event.getY();
                    // 连接状态
                    if (mBubbleState == BUBBLE_STATE_CONNECT){
                        if (mDist < mMaxDist){
                            //当拖拽距离在指定范围内,调整不动圆半径
                            mBubStillRadius = mBubbleRadius - mDist / 8;
                        } else {
                            //超过指定范围,分离状态
                            mBubbleState = BUBBLE_STATE_APART;
                        }
                    }
                    // 重绘
                    invalidate();
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                // 连接状态下松开
                if (mBubbleState == BUBBLE_STATE_CONNECT) {
                    // 回弹效果
                    startBubbleRestAnim();
                } else if (mBubbleState == BUBBLE_STATE_APART){
                    // 分离状态下松开
                    if (mDist < 2 * mBubbleRadius){
                        // 距离较近时,回弹,不爆炸
                        startBubbleRestAnim();
                    } else {
                        // 爆炸效果
                        startBubbleBurstAnim();
                    }
                }
                break;
            }
        }
        return true;
    }

    public void reset(){
        // 重置状态
        mBubbleState = BUBBLE_STATE_DEFAUL;
        // 重置可动气泡圆心位置
        mBubMoveableCenter = new PointF(mBubStillCenter.x, mBubStillCenter.y);
        // 重绘
        invalidate();
    }
}

另外附上源码,需要的小伙伴可以去下载,其实这个自定义控件练手很适合,一定要自己手写下,理解原理。

https://download.csdn.net/download/xifei66/13124488 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值