在仿QQ拖动删除未读消息个数气泡这篇文章中,模仿了QQ的删除未读消息气泡,不过也遗留了一个问题,当时为了让气泡能够在全屏范围内拖动,不能将其放在布局文件xml中,而是采用了在主布局加载完成后用addView方法动态加载气泡,这种方式不太好,因为它需要自己计算在全屏范围中,气泡放在什么地方,这需要参造物,而且如果把气泡放在listview中的话,也不可能给每个Item都去动态计算增加气泡,所以这种方式不够完善。
经过尝试,改用另一种方式来处理,可以将气泡放置在布局xml中,也不需要用addView来动态添加了。这篇博客就来讲解气泡的全屏拖动,而随手指移动用贝塞尔曲线画连接请参考上篇博客,这里不再重复讲解。来看效果
先说下原理。为了实现气泡的全屏拖动,我们需要两个自定义控件,一个RoundNumber,它就是放置在XML中的气泡控件,不过它并不随着手势移动,因为它的大小是有限的,移动了也看不到,而另外一个BounceCircle,它的大小是全屏的,随手势移动的是它。RoundNumber可以有多个,但BounceCircle只有一个。初始状态时,BounceCircle是隐藏的,RoundNumber是显示的,当我们按下RoundNumber时,立即将BounceCircle显示,而将RoundNumber隐藏,要注意,这时手指移动依然会是在RoundNumber的onTouchEvent事件中,而不是BounceCircle的,为了让BounceCircle随手势移动,我们要在RoundNumber的onTouchEvent中,调用回调方法,而最终调用到BounceCircle的onDraw函数,来实现随手势移动。
下面看具体实现,首先是RoundNumber
public class RoundNumber extends View {
private int radius; // 圆形半径
private float circleX; // 圆心x坐标
private float circleY; // 圆心y坐标
private Paint circlePaint; // 圆形画笔
private TextPaint textPaint; // 文字画笔
private int textSize; // 字体大小,单位SP
private Paint.FontMetrics textFontMetrics; // 字体
private float textMove; // 为了让文字居中,需要移动的距离
private String message = "1";
private boolean firstInit = true;
private Context mContext;
private ClickListener mClickListener;
public RoundNumber(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
initPaint();
}
/**
* 初始化
*/
private void initPaint() {
circlePaint = new Paint();
circlePaint.setColor(Color.RED);
circlePaint.setAntiAlias(true);
textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setColor(Color.WHITE);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (firstInit) {
firstInit = false;
radius = w / 2;
int[] position = new int[2];
getLocationOnScreen(position);
circleX = radius;
circleY = radius;
textSize = radius; // 根据圆半径来设置字体大小
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setTextSize(Util.sp2px(mContext, textSize));
textFontMetrics = textPaint.getFontMetrics();
textMove = -textFontMetrics.ascent - (-textFontMetrics.ascent + textFontMetrics.descent) / 2; // drawText从baseline开始,baseline的值为0,baseline的上面为负值,baseline的下面为正值,即这里ascent为负值,descent为正值,比如ascent为-20,descent为5,那需要移动的距离就是20 - (20 + 5)/ 2
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(circleX, circleY, radius, circlePaint);
canvas.drawText(message, circleX, circleY + textMove, textPaint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (MainActivity.isTouchable) {
if (mClickListener != null) {
MainActivity.isTouchable = false;
getParent().requestDisallowInterceptTouchEvent(true); // 不允许父控件处理TouchEvent,当父控件为ListView这种本身可滑动的控件时必须要控制
mClickListener.onDown();
}
return true;
}
return false;
case MotionEvent.ACTION_MOVE:
if (mClickListener != null) { // 注意这里要用getRaw来获取手指当前所处的相对整个屏幕的坐标
mClickListener.onMove(event.getRawX(), event.getRawY() - Util.getTopBarHeight((Activity) mContext));
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (mClickListener != null) {
getParent().requestDisallowInterceptTouchEvent(false); // 将控制权还给父控件
mClickListener.onUp();
}
break;
default:
break;
}
return super.onTouchEvent(event);
}
/**
* 设置显示内容
* @param message
*/
public void setMessage(String message) {
this.message = message;
}
/**
* 获取显示内容
* @return
*/
public String getMessage() {
return message;
}
public interface ClickListener {
void onDown(); // 手指按下
void onMove(float curX, float curY); // 手指移动
void onUp(); // 手指抬起
}
public void setClickListener(ClickListener listener) {
mClickListener = listener;
}
}
可以看到,RoundNumber 这个自定义控件,它本身的onDraw方法中,只画了一个红色圆,和圆中的白色数字,而在它的onTouchEvent中,我们都是使用的都是类似mClickListener.***这样的方法,而这个mClickListener是我们自己定义的一个接口,它用来回调,在回调中去真正处理随手势移动的逻辑。另外,这里还需要注意的是,在手指按下时,我们调用了getParent().requestDisallowInterceptTouchEvent(true);方法,这是用来让父控件放弃控制权,因为在一些本身可以随手指移动的控件,例如ListView,不加这个处理,会导致事件被父类消费了,所以这里要控制一下,同样道理,手指抬起时,要调用getParent().requestDisallowInterceptTouchEvent(false);将控制权还给父类。
另外这里还用到了一个全局变量MainActivity.isTouchable,这个值初始情况为true,在有RoundNumber被按下时,就会赋值为false,完后等手指抬起,处理完动画后,才会重新变成true,设置这样一个变量是为了防止同时有多个RoundNumber被处理,因为只有一个BounceCircle。完后在MainActivity中去设置回调
unreadMessage = (RoundNumber) findViewById(R.id.unread_message);
unreadMessage.setMessage("3");
unreadMessage.setClickListener(new RoundNumber.ClickListener() {
@Override
public void onDown() {
int[] position = new int[2];
unreadMessage.getLocationOnScreen(position);
int radius = unreadMessage.getWidth() / 2;
circle.down(radius, position[0] + radius, position[1] - Util.getTopBarHeight(MainActivity.this) + radius, unreadMessage.getMessage());
circle.setVisibility(View.VISIBLE); // 显示全屏范围的BounceCircle
unreadMessage.setVisibility(View.INVISIBLE); // 隐藏固定范围的RoundNumber
circle.setOrginView(unreadMessage);
}
@Override
public void onMove(float curX, float curY) {
circle.move(curX, curY);
}
@Override
public void onUp() {
circle.up();
}
});
BounceCircle的代码如下:
public class BounceCircle extends View {
private Context mContext;
private Paint circlePaint; // 圆形/连线画笔
private TextPaint textPaint; // 文字画笔
private Paint.FontMetrics textFontMetrics; // 字体
private Path path;
private int radius; // 移动圆形半径
private float textMove; // 为了让文字居中,需要移动的距离
private float curX; // 当前x坐标
private float curY; // 当前y坐标
private float circleX; // 固定圆的圆心x坐标
private float circleY; // 固定圆的圆心y坐标
private float ratio = 1; // 圆缩放的比例,随着手指的移动,固定的圆越来越小
private float ratioLimit = 0.2f; // 固定圆最小的缩放比例,小于该比例时就直接消失
private int distanceLimit = 100; // 固定圆和移动圆的圆心之间距离的限值,单位DP(配合ratioLimit使用)
private int textSize; // 字体大小,单位SP
private int animationTime = 200; // 抖动动画执行的时间
private int animationTimes = 1; // 抖动动画执行次数
private boolean needDraw = true; // 是否需要执行onDraw方法
private FinishListener mFinishListener; // 自定义接口,用来回调
private String message = "1"; // 显示的数字的初始值
private Bitmap[] explosionAnim; // 爆炸动画
private boolean animStart; // 动画开始
private int animNumber = 5; // 动画帧的个数
private int curAnimNumber; // 动画播放的当前帧
private int animInterval = 200; // 动画帧之间的间隔
private int animWidth; // 动画帧的宽度
private int animHeight; // 动画帧的高度
private View originalView;
public BounceCircle(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
initPaint();
}
/**
* 初始化Paint
*/
private void initPaint() {
circlePaint = new Paint();
circlePaint.setColor(Color.RED);
circlePaint.setAntiAlias(true);
distanceLimit = Util.dip2px(mContext, distanceLimit);
textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setColor(Color.WHITE);
path = new Path();
}
/**
* 初始化爆炸动画
*/
private void initAnim() {
if (explosionAnim == null) {
explosionAnim = new Bitmap[animNumber];
explosionAnim[0] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_one);
explosionAnim[1] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_two);
explosionAnim[2] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_three);
explosionAnim[3] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_four);
explosionAnim[4] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_five);
// 动画每帧的长宽都是一样的,取一个即可
animWidth = explosionAnim[0].getWidth();
animHeight = explosionAnim[0].getHeight();
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (needDraw) {
// 画固定圆
if (ratio >= ratioLimit) {
canvas.drawCircle(circleX, circleY, radius * ratio, circlePaint);
}
// 画移动圆和连线
if (curX != 0 && curY != 0) {
canvas.drawCircle(curX, curY, radius, circlePaint);
if (ratio >= ratioLimit) {
drawLinePath(canvas);
}
}
// 数字要最后画,否则会被连线遮掩
if (curX != 0 && curY != 0) { // 移动圆里面的数字
canvas.drawText(message, curX, curY + textMove, textPaint);
} else { // 只有初始时需要绘制固定圆里面的数字
canvas.drawText(message, circleX, circleY + textMove, textPaint);
}
}
if (animStart) { // 动画进行中
if (curAnimNumber < animNumber) {
canvas.drawBitmap(explosionAnim[curAnimNumber], curX - animWidth / 2, curY - animHeight / 2, null);
curAnimNumber++;
if (curAnimNumber == 1) { // 第一帧立即执行
invalidate();
} else { // 其余帧每隔固定时间执行
postInvalidateDelayed(animInterval);
}
} else { // 动画结束
animStart = false;
curAnimNumber = 0;
recycleBitmap();
setVisibility(View.INVISIBLE); // 隐藏BounceCircle
curX = 0;
curY = 0;
MainActivity.isTouchable = true;
// 删除后的回调
if (mFinishListener != null) {
mFinishListener.onFinish();
}
}
}
}
/**
* 回收Bitmap资源
*/
private void recycleBitmap() {
if (explosionAnim != null && explosionAnim.length != 0) {
for (int i = 0; i < explosionAnim.length; i++) {
if (explosionAnim[i] != null && !explosionAnim[i].isRecycled()) {
explosionAnim[i].recycle();
explosionAnim[i] = null;
}
}
explosionAnim = null;
}
}
/**
* 画固定圆和移动圆之间的连线
* @param canvas
*/
private void drawLinePath(Canvas canvas) {
path.reset();
float distance = (float) Util.distance(circleX, circleY, curX, curY); // 移动圆和固定圆圆心之间的距离
float sina = (curY - circleY) / distance; // 移动圆圆心和固定圆圆心之间的连线与X轴相交形成的角度的sin值
float cosa = (circleX - curX) / distance; // 移动圆圆心和固定圆圆心之间的连线与X轴相交形成的角度的cos值
path.moveTo(circleX - sina * radius * ratio, circleY - cosa * radius * ratio); // A点坐标
path.lineTo(circleX + sina * radius * ratio, circleY + cosa * radius * ratio); // AB连线
path.quadTo((circleX + curX) / 2, (circleY + curY) / 2, curX + sina * radius, curY + cosa * radius); // 控制点为两个圆心的中间点,二阶贝塞尔曲线,BC连线
path.lineTo(curX - sina * radius, curY - cosa * radius); // CD连线
path.quadTo((circleX + curX) / 2, (circleY + curY) / 2, circleX - sina * radius * ratio, circleY - cosa * radius * ratio); // 控制点也是两个圆心的中间点,二阶贝塞尔曲线,DA连线
canvas.drawPath(path, circlePaint);
}
/**
* 计算固定圆缩放的比例
* @param distance
* @return
*/
private void calculateRatio(float distance) {
ratio = (distanceLimit - distance) / distanceLimit;
}
/**
* 抖动动画
* @param counts
*/
public void shakeAnimation(int counts) {
// 避免动画抖动的频率过大,所以除以2,另外,抖动的方向跟手指滑动的方向要相反
Animation translateAnimation = new TranslateAnimation((circleX - curX) / 2, 0, (circleY - curY) / 2, 0);
translateAnimation.setInterpolator(new CycleInterpolator(counts));
translateAnimation.setDuration(animationTime);
startAnimation(translateAnimation);
translateAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) { // 抖动动画结束时,显示以前的RoundNumber,隐藏BounceCircle
if (originalView != null) {
originalView.setVisibility(View.VISIBLE);
}
setVisibility(View.INVISIBLE);
MainActivity.isTouchable = true;
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
}
public interface FinishListener {
void onFinish();
}
public void setFinishListener(FinishListener finishListener) {
mFinishListener = finishListener;
}
public void move(float curX, float curY) {
this.curX = curX;
this.curY = curY;
calculateRatio((float) Util.distance(curX, curY, circleX, circleY));
invalidate();
}
public void up() {
if (ratio > ratioLimit) { // 没有超出最大移动距离,手抬起时需要让移动圆回到固定圆的位置
shakeAnimation(animationTimes);
curX = 0;
curY = 0;
ratio = 1;
} else { // 超出最大移动距离
needDraw = false;
animStart = true;
initAnim();
}
invalidate();
}
public void down(int radius, float circleX, float circleY, String message) {
needDraw = true; // 由于BounceCircle是公用的,每次进来时都要确保needDraw的值为true
this.radius = radius;
this.circleX = circleX;
this.circleY = circleY;
this.message = message;
textSize = radius;
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setTextSize(Util.sp2px(mContext, textSize));
textFontMetrics = textPaint.getFontMetrics();
textMove = -textFontMetrics.ascent - (-textFontMetrics.ascent + textFontMetrics.descent) / 2; // drawText从baseline开始,baseline的值为0,baseline的上面为负值,baseline的下面为正值,即这里ascent为负值,descent为正值,比如ascent为-20,descent为5,那需要移动的距离就是20 - (20 + 5)/ 2
invalidate();
}
/**
* 设置按下时被隐藏的View
* @param view
*/
public void setOrginView(View view) {
originalView = view;
}
}
源码下载