效果图
1.气泡会随着手指的移动而移动,
2.在一定范围内,气泡和粘连小球都存在,超出此范围粘连小球会消失
3.当超出最大距离然后松开手后气泡会消失,然后有一段动画
4.当超出最大距离后,只要不松开手指;气泡还会随着手指的移动,当你再移动到开始位置一定范围内松开手指,气泡会复位
首先自定义属性
<!--高仿QQ未读消息气泡拖拽黏连效果-->
<declare-styleable name="DragButtonView">
<!--气泡圆的颜色-->
<attr name="bubbleColor" format="color"/>
<!--气泡的半径-->
<attr name="bubbleRadius" format="dimension"/>
<!--气泡上的文本-->
<attr name="text"/>
<!--文本的颜色-->
<attr name="textColor" format="color"/>
<!--文本的大小-->
<attr name="textSize" />
</declare-styleable>
构造方法中获取属性和初始化参数
public DragButtonView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = this.getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.DragButtonView,defStyleAttr,0);
if (a != null) {
int count = a.getIndexCount();
for (int i = 0 ; i < count; i++) {
int attr = a.getIndex(i);
switch(attr) {
case R.styleable.DragButtonView_bubbleColor:
mBubbleColor = a.getColor(attr,Color.YELLOW);
break;
case R.styleable.DragButtonView_bubbleRadius:
mBubbleRadius = (int) a.getDimension(attr, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP
,12f,getResources().getDisplayMetrics()));
mCircleRadius = mBubbleRadius;
break;
case R.styleable.DragButtonView_text:
text = a.getString(attr);
break;
case R.styleable.DragButtonView_textColor:
mTextColor = a.getColor(attr,Color.RED);
break;
case R.styleable.DragButtonView_textSize:
mTextSize = a.getDimensionPixelSize(attr, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP
,12,getResources().getDisplayMetrics()));
break;
}
}
}
a.recycle();
mState = STATE_DEFAULT;
maxD = 8 * mBubbleRadius;
mBubblePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBubblePaint.setColor(mBubbleColor);
mBubblePaint.setAntiAlias(true);
mBubblePaint.setStyle(Paint.Style.FILL);
mBesierPath = new Path();
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(mTextColor);
mTextPaint.setTextSize(mTextSize);
mExplosiontPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mExplosiontPaint.setFilterBitmap(true);
mExplosionRect = new Rect();
mExplosionBitmaps = new Bitmap[mExplosionDrawables.length];
for (int i = 0; i < mExplosionDrawables.length; i ++) {
//将气泡爆炸的drawable转换成bitmap
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),mExplosionDrawables[i]);
mExplosionBitmaps[i] = bitmap;
}
}
复写
onMeasure方法
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measuredDimension(widthMeasureSpec), measuredDimension(heightMeasureSpec));
}
private int measuredDimension(int measureSpec) {
int result;
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else {
result = (int) (2 * mBubbleRadius);
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
在
onSizeChanged方法中获取气泡小球和粘连小球的圆心坐标,此方法在
onDraw之前调用
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mBubbleCenterX = w / 2;
mBubbleCenterY = h / 2;
mCircleCenterX = mBubbleCenterX;
mCircleCenterY = mBubbleCenterY;
}
先确定控件的几种状态:
/**
* 气泡的状态=
*/
private int mState;//气泡状态
private static final int STATE_DEFAULT = 0x00;//默认.无法拖拽
private static final int STATE_DRAG = 0x01;//拖拽
private static final int STATE_MOVE = 0x02;//移动
private static final int STATE_DISMISS = 0x03;//消失
开始气泡式默认状态,无法拖拽,当手指按在上面在一定范围移动的时候就是拖拽状态,当超出最大范围粘连小球消失后,再拖动就进入move状态,最后是消失状态
复写onTouchEvent,来控制手指的触摸事件
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch(action) {
...
首先是
Down事件
case MotionEvent.ACTION_DOWN:
if (mState != STATE_DISMISS){
getParent().requestDisallowInterceptTouchEvent(true);
d = (float) Math.hypot(event.getX() - mBubbleCenterX,event.getY() - mBubbleCenterY);
if (d < mBubbleRadius + maxD / 4) {
//当之间坐标在圆内的时候,才认为是可拖拽的
//一般的气泡比较小.增加maxD / 4是为了更容易的拖拽
mState = STATE_DRAG;
}else {
mState = STATE_DEFAULT;
}
}
break;
在触发Down事件必须是气泡没有消失才行,当气泡消失之后在按屏幕上没有任何反应的,因为此控件很可能被用在ListView等控件中,所以在Down事件和Move事件中都要调用
getParent().requestDisallowInterceptTouchEvent(true);
这行代码来请求父控件不要拦截此事件.d在这里是触摸点和气泡圆心之间的距离.吧这个距离加大maxD / 4是为了让触摸更容易获取.在此方位内就把状态改为可拖拽,不在此范围就改为默认状态,默认状态下是不能拖拽和移动的
然后是Move事件
case MotionEvent.ACTION_MOVE:
if (mState != STATE_DEFAULT) {
getParent().requestDisallowInterceptTouchEvent(true);
mBubbleCenterX = event.getX();
mBubbleCenterY = event.getY();
//计算气泡和粘连小球圆心之间的距离
d = (float) Math.hypot(mBubbleCenterX - mCircleCenterX,mBubbleCenterY - mCircleCenterY);
if (mState == STATE_DRAG) {
if (d < maxD - maxD / 4){
//使粘连小球半径逐渐变小
//减去maxD / 4是为了让粘连小球半径小道一定程度时直接消失
mCircleRadius = mBubbleRadius - d / 8;
if (mOnBubbleStateListener != null) {
mOnBubbleStateListener.onDrag();
}
}else {//间距大于可粘连的最大值
mState = STATE_MOVE;//改为移动状态
if (mOnBubbleStateListener != null)
mOnBubbleStateListener.onMove();
}
}
invalidate();
}
break;
当触发 Move事件的时候,就要改变气泡的圆心为当前触摸的点,这样通过
invalidate()
此方法就可以实现气泡跟着手指一动的效果,d在这里指的是气泡的圆心和粘连小球的圆心之间的距离.
maxD指的就是这俩圆心的最大距离.超出此距离粘连小球就得消失,随着d的不断增大粘连小球的半径不断减小,减小的值就是d/8,当d大于一定的值的时候.就吧小球的状态改为Move,而在move状态下粘连小球其实已经消失了.最后一定要在每次出发Move事件的时候都要调用invalidate()方法,来重新绘制控件,要不然控件的参数虽然变了,但是显示在屏幕上的还是之前的状态.
然后是Up事件:
getParent().requestDisallowInterceptTouchEvent(false);
//在手指松开时,气泡恢复原来的位置,并颤动一下
if (mState == STATE_DRAG) {
setBubbleRestoreAnim();
}else if (mState == STATE_MOVE){
if (d < mBubbleRadius * 2) {
setBubbleRestoreAnim();
}else {
setBubbleDismissAnim();
}
}
break;
当触发Up事件的时候,大体上说有两种情况,一种是当前控件处于Drag状态,也就是拖拽状态,那么此时松开屏幕之后,就要气泡恢复原来,状态变成DEFULT,第二种情况就是控件处于Move状态,a:如果当前d的距离比较大,那么气泡就消失,状态变为DISMISS,b:如果当前的距离小于气泡的直径,那么气泡复位,状态变成DEFULT.
复位动画:
private void setBubbleRestoreAnim() {
ValueAnimator anim = ValueAnimator.ofObject(new PointFEvaluator(),
new PointF(mBubbleCenterX,mBubbleCenterY),
new PointF(mCircleCenterX,mCircleCenterY));
anim.setDuration(500);
//使用overshootInterpolator差值器达到颤动效果
anim.setInterpolator(new OvershootInterpolator());
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
PointF point = (PointF) animation.getAnimatedValue();
mBubbleCenterX = point.x;
mBubbleCenterY = point.y;
invalidate();
}
});
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//动画结束后状态改为默认
mState = STATE_DEFAULT;
if (mOnBubbleStateListener != null) {
mOnBubbleStateListener.onRestore();
}
}
});
anim.start();
}
这个动画也就是气泡的圆心从当前的值平滑的国度成粘连小球的圆心,而且还有个颤动的效果,这个效果用
anim.setInterpolator(new OvershootInterpolator());这行代码实现,至于 Interpolator的用法请大家自行百度,可以实现很多效果,比如加速,减速,加速再减速等动画效果
气泡消失的动画:
private void setBubbleDismissAnim() {
mState = STATE_DISMISS;
mIsExplosionAnimStart = true;
if(mOnBubbleStateListener != null) {
mOnBubbleStateListener.onDismiss();
}
//做一个int型属性动画,从零开始,到气泡爆炸图片数组个数结束
ValueAnimator anim = ValueAnimator.ofInt(0,mExplosionDrawables.length);
anim.setInterpolator(new LinearInterpolator());
anim.setDuration(1000);
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//拿到当前的值并绘制
mCurExplosionIndex = (int) animation.getAnimatedValue();
invalidate();
}
});
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//动画结束后改变状态
mIsExplosionAnimStart = false;
}
});
anim.start();
}
首先把状态改为DISMISS,这个动画的实现其实是几个图片实现的,利用 canvas,DrawBitmap()方法来绘制不同的bitmap达到这个效果.
计算二阶贝塞尔曲线做需要的起点、终点和控制点坐标,顺序是moveTo A, quadTo B, lineTo C, quadTo D, close
先来张示意图:
/**
* 计算二阶贝塞尔曲线需要的起点终点控制点坐标
*/
private void calculateBezierCoordinate() {
//计算控制点坐标
mControlX = (mBubbleCenterX + mCircleCenterX) / 2;
mControlY = (mBubbleCenterY + mCircleCenterY) / 2;
//计算两条二阶贝塞尔曲线的起点和终点
float sin = (mBubbleCenterY - mCircleCenterY) / d;
float cos = (mBubbleCenterX - mCircleCenterX) / d;
mCircleStartX = mCircleCenterX - mCircleRadius * sin;
mCircleStartY = mCircleCenterY + mCircleRadius * cos;
mCircleEndX = mCircleCenterX + mCircleRadius * sin;
mCircleEndY = mCircleCenterY - mCircleRadius * cos;
mBubbleStartX = mBubbleCenterX + mBubbleRadius * sin;
mBubbleStartY = mBubbleCenterY - mBubbleRadius * cos;
mBubbleEndX = mBubbleCenterX - mBubbleRadius * sin;
mBubbleEndY = mBubbleCenterY + mBubbleRadius * cos;
}
这里其实也就是这个项目最难的地方了,很多人都不知道这些值怎么计算,我想我们并不是太菜了,而是把这些初中的知识都忘了而已,我相信配合上面的图大家一定会豁然开朗
最后我们来说下onDraw方法中的代码实现:
绘制拖拽气泡:
//画拖拽气泡
if (mState != STATE_DISMISS)
canvas.drawCircle(mBubbleCenterX,mBubbleCenterY,mBubbleRadius,mBubblePaint);
绘制粘连小球和贝塞尔曲线
if (mState == STATE_DRAG && d < maxD - maxD / 4) {
//画粘连小球
canvas.drawCircle(mCircleCenterX,mCircleCenterY,mCircleRadius,mBubblePaint);
//计算二阶贝塞尔曲线需要的起点终点控制点坐标
calculateBezierCoordinate();
//画二阶贝塞尔曲线
mBesierPath.reset();
mBesierPath.moveTo(mCircleStartX,mCircleStartY);
mBesierPath.quadTo(mControlX,mControlY,mBubbleEndX,mBubbleEndY);
mBesierPath.lineTo(mBubbleStartX,mBubbleStartY);
mBesierPath.quadTo(mControlX,mControlY,mCircleEndX,mCircleEndY);
mBesierPath.close();
canvas.drawPath(mBesierPath,mBubblePaint);
}
当控件的状态是Drag也就是拖拽状态的时候且粘连小球圆心和气泡的圆心在一定的范围内的时候才会绘制粘连小球和贝塞尔曲线
绘制消息文本
//画消息个数的文本
if (mState != STATE_DISMISS && !TextUtils.isEmpty(text)) {
Rect mRect = new Rect(getPaddingLeft(),getPaddingTop(),getPaddingLeft() + getWidth(),getPaddingTop() + getHeight());
mTextPaint.getTextBounds(text,0,text.length(),mRect);
canvas.drawText(text,mBubbleCenterX - mRect.width() / 2,
mBubbleCenterY + mRect.height() / 2,mTextPaint);
}
这个没有什么好说的,只要把握住文本的中心就是气泡的圆心这一点就么问题
//绘制气泡最后消失的时候的爆炸动画
//画爆炸动画
if (mIsExplosionAnimStart && mCurExplosionIndex < mExplosionDrawables.length) {
//设置气泡爆炸图片的范围
mExplosionRect.set((int)(mBubbleCenterX - mBubbleRadius),(int)(mBubbleCenterY - mBubbleRadius)
,(int)(mBubbleCenterX + mBubbleRadius),(int)(mBubbleCenterY + mBubbleRadius));
canvas.drawBitmap(mExplosionBitmaps[mCurExplosionIndex],null,mExplosionRect,mExplosiontPaint);
}
首先确定爆炸的bitmap的rect范围就是气泡的范围.画出对应的bitmap就over了
下面是PointFEvaluator类和对外开放的接口的代码实现:
public class PointFEvaluator implements TypeEvaluator<PointF> {
@Override
public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
float x = startValue.x + fraction * (endValue.x - startValue.x);
float y = startValue.y + fraction * (endValue.y - startValue.y);
return new PointF(x,y);
}
}
public interface OnBubbleStateListener{
/**
* 拖拽
*/
void onDrag();
/**
* 移动气泡
*/
void onMove();
/**
* 气泡恢复原来位置
*/
void onRestore();
/**
* 气泡消失
*/
void onDismiss();
}
/**
* 设置气泡状态的监听器
*/
public void setOnBubbleStateListener(OnBubbleStateListener listener) {
mOnBubbleStateListener = listener;
}
上面效果的XML代码:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_qq"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:clipChildren="false"
android:background="#333333"
tools:context="com.example.bailong.myapplication.QQActivity">
<com.example.bailong.myapplication.view.DragButtonView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:bubbleColor="#ff0000"
app:bubbleRadius="12dp"
app:text="99+"
app:textColor="#ffffff"
app:textSize="12sp"
/>
</LinearLayout>
在这个控件中,气泡会移动到自己范围的外面去,这就要靠父布局的
android:clipChildren="false"允许子控件超出限制的范围