仿QQ未读消息气泡,可拖拽删除,粘连效果。
使用方法
依赖
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);
}
}
}
其中绘制两圆间粘连效果的贝塞尔曲线我们需要去计算绘制的起点、控制点和中心点。这里我是参照这篇博文写的,因为初中数学早都忘干净了,实在算不出来。
如果有朋友想自己写写看,也可以参照这张非常有用的图,当然我看不懂,所以我就不讲了,直接贴上代码。
贝塞尔曲线控制点计算.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未读消息气泡拖拽黏连效果》如有侵权会立即删除并停止使用。