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 实现的,在绘制的过程涉及到了贝塞尔曲线,下面分析下实现的思路和一些细节:
// 一定要分状态 文字
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