Android贝塞尔曲线初步学习第二课 仿QQ未读消息气泡拖拽黏连效果
上一节初步了解了android端的贝塞尔曲线,这一节就举个栗子练习一下,仿qq未读消息气泡,是最经典的练习贝塞尔曲线的东东,效果如下
附上github源码地址:https://github.com/monkeymushroom/dragbubbleview
欢迎star~
大体思路就是画两个圆,一个黏连小球固定在一个点上,一个气泡小球跟随手指的滑动改变坐标。随着两个圆间距越来越大,黏连小球半径越来越小。当间距小于一定值,松开手指气泡小球会恢复原来位置;当间距超过一定值之后,黏连小球消失,气泡小球继续跟随手指移动,此时手指松开,气泡小球消失~
1、首先老一套~新建attrs.xml文件,编写自定义属性,新建dragbubbleview继承view,重写构造方法,获取自定义属性值,初始化paint、path等东东,重写onmeasure计算宽高,这里不再啰嗦~
2、在onsizechanged方法中确定黏连小球和气泡小球的圆心坐标,这里我们取宽高的一半:
@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; }
3、经分析气泡小球有以下几个状态:默认、拖拽、移动、消失,我们这里定义一下,方便根据不同的状态分析不同情况:
/* 气泡的状态 */ 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;
4、重写ontouchevent方法,其中d代表两圆圆心间距,maxd代表可拖拽的最大间距:
@override public boolean ontouchevent(motionevent event) { switch (event.getaction()) { case motionevent.action_down: if (mstate != state_dismiss) { 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; case motionevent.action_move: if (mstate != state_default) { mbubblecenterx = event.getx(); mbubblecentery = event.gety(); //计算气泡圆心与黏连小球圆心的间距 d = (float) math.hypot(mbubblecenterx - mcirclecenterx, mbubblecentery - mcirclecentery); //float d = (float) math.sqrt(math.pow(mbubblecenterx - mcirclecenterx, 2) //+ math.pow(mbubblecentery - mcirclecentery, 2)); 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; case motionevent.action_up: if (mstate == state_drag) {//正在拖拽时松开手指,气泡恢复原来位置并颤动一下 setbubblerestoreanim(); } else if (mstate == state_move) {//正在移动时松开手指 //如果在移动状态下间距回到两倍半径之内,我们认为用户不想取消该气泡 if (d < 2 * mbubbleradius) {//那么气泡恢复原来位置并颤动一下 setbubblerestoreanim(); } else {//气泡消失 setbubbledismissanim(); } } break; } return true; }
如果控件外面有嵌套listview、recyclerview等拦截焦点的控件,那就在action_down中请求父控件不拦截事件:
getparent().requestdisallowintercepttouchevent(true);
然后action_up再把事件还回去:
getparent().requestdisallowintercepttouchevent(false);
5、在ondraw方法中画圆、画贝赛尔曲线、画消息个数文本:
@override protected void ondraw(canvas canvas) { super.ondraw(canvas); //画拖拽气泡 canvas.drawcircle(mbubblecenterx, mbubblecentery, mbubbleradius, mbubblepaint); if (mstate == state_drag && d < maxd - 48) { //画黏连小圆 canvas.drawcircle(mcirclecenterx, mcirclecentery, mcircleradius, mbubblepaint); //计算二阶贝塞尔曲线做需要的起点、终点和控制点坐标 calculatebeziercoordinate(); //画二阶贝赛尔曲线 mbezierpath.reset(); mbezierpath.moveto(mcirclestartx, mcirclestarty); mbezierpath.quadto(mcontrolx, mcontroly, mbubbleendx, mbubbleendy); mbezierpath.lineto(mbubblestartx, mbubblestarty); mbezierpath.quadto(mcontrolx, mcontroly, mcircleendx, mcircleendy); mbezierpath.close(); canvas.drawpath(mbezierpath, mbubblepaint); } //画消息个数的文本 if (mstate != state_dismiss && !textutils.isempty(mtext)) { mtextpaint.gettextbounds(mtext, 0, mtext.length(), mtextrect); canvas.drawtext(mtext, mbubblecenterx - mtextrect.width() / 2, mbubblecentery + mtextrect.height() / 2, mtextpaint); } }
其中计算二阶贝塞尔曲线做需要的起点、终点和控制点坐标,顺序是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; mbubbleendx = mbubblecenterx - mbubbleradius * sin; mbubbleendy = mbubblecentery + mbubbleradius * cos; mbubblestartx = mbubblecenterx + mbubbleradius * sin; mbubblestarty = mbubblecentery - mbubbleradius * cos; mcircleendx = mcirclecenterx + mcircleradius * sin; mcircleendy = mcirclecentery - mcircleradius * cos; }
6、气泡复原的动画,使用估值器计算坐标
/** * 设置气泡复原的动画 */ private void setbubblerestoreanim() { valueanimator anim = valueanimator.ofobject(new pointfevaluator(), new pointf(mbubblecenterx, mbubblecentery), new pointf(mcirclecenterx, mcirclecentery)); anim.setduration(200); //使用overshootinterpolator差值器达到颤动效果 anim.setinterpolator(new overshootinterpolator(5)); anim.addupdatelistener(new valueanimator.animatorupdatelistener() { @override public void onanimationupdate(valueanimator animation) { pointf curpoint = (pointf) animation.getanimatedvalue(); mbubblecenterx = curpoint.x; mbubblecentery = curpoint.y; invalidate(); } }); anim.addlistener(new animatorlisteneradapter() { @override public void onanimationend(animator animation) { //动画结束后状态改为默认 mstate = state_default; if (monbubblestatelistener != null) { monbubblestatelistener.onrestore(); } } }); anim.start(); }
/** * pointf动画估值器 */ public class pointfevaluator 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); } }
7、顺便来个气泡状态的监听器,方便外部调用监听其状态:
/** * 气泡状态的监听器 */ public interface onbubblestatelistener { /** * 拖拽气泡 */ void ondrag(); /** * 移动气泡 */ void onmove(); /** * 气泡恢复原来位置 */ void onrestore(); /** * 气泡消失 */ void ondismiss(); } /** * 设置气泡状态的监听器 */ public void setonbubblestatelistener(onbubblestatelistener onbubblestatelistener) { monbubblestatelistener = onbubblestatelistener; }
8、关于气泡爆炸的动画,思路就是放几张图片到drawable里,然后动态计数重绘,在ondraw中调用canvas.drawbitmap()方法,具体如下:
/* 气泡爆炸的图片id数组 */ private int[] mexplosiondrawables = {r.drawable.explosion_one, r.drawable.explosion_two , r.drawable.explosion_three, r.drawable.explosion_four, r.drawable.explosion_five}; /* 气泡爆炸的bitmap数组 */ private bitmap[] mexplosionbitmaps; /* 气泡爆炸当前进行到第几张 */ private int mcurexplosionindex; /* 气泡爆炸动画是否开始 */ private boolean misexplosionanimstart = false;
在构造方法中:
mexplosionpaint = new paint(paint.anti_alias_flag); mexplosionpaint.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; }
然后在手指抬起的时候使用如下动画:
/** * 设置气泡消失的动画 */ private void setbubbledismissanim() { mstate = state_dismiss;//气泡改为消失状态 misexplosionanimstart = true; if (monbubblestatelistener != null) { monbubblestatelistener.ondismiss(); } //做一个int型属性动画,从0开始,到气泡爆炸图片数组个数结束 valueanimator anim = valueanimator.ofint(0, mexplosiondrawables.length); anim.setinterpolator(new linearinterpolator()); anim.setduration(500); 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(); }
最后在ondraw中:
if (misexplosionanimstart && mcurexplosionindex < mexplosiondrawables.length) { //设置气泡爆炸图片的位置 mexplosionrect.set((int) (mbubblecenterx - mbubbleradius), (int) (mbubblecentery - mbubbleradius) , (int) (mbubblecenterx + mbubbleradius), (int) (mbubblecentery + mbubbleradius)); //根据当前进行到爆炸气泡的位置index来绘制爆炸气泡bitmap canvas.drawbitmap(mexplosionbitmaps[mcurexplosionindex], null, mexplosionrect, mexplosionpaint); }
9、在布局文件中使用该控件,并使用自定义属性:
<?xml version="1.0" encoding="utf-8"?> <relativelayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:monkey="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:clipchildren="false" tools:context=".mainactivity"> <com.monkey.dragpopview.dragbubbleview android:id="@+id/dragbubbleview" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerinparent="true" monkey:bubblecolor="#ff0000" monkey:bubbleradius="12dp" monkey:text="99+" monkey:textcolor="#ffffff" monkey:textsize="12sp" /> </relativelayout>
其中 android:clipchildren=”false” 这个属性可以使根布局下的子控件超出本身控件范围的大小,加上这个属性就可以满屏幕随意拖拽而不必拘泥于它本身的大小了,炒鸡方便~
还有如果觉得在属性中设置消息个数不方便,需要在代码中动态获取数据并设置的话,只要在dragbubbleview中添加一个方法即可
public void settext(string text){ mtext = text; invalidate(); }
10、在mainactivity中:
dragbubbleview dragbubbleview = (dragbubbleview) findviewbyid(r.id.dragbubbleview); dragbubbleview.settext("99+"); dragbubbleview.setonbubblestatelistener(new dragbubbleview.onbubblestatelistener() { @override public void ondrag() { log.e("---> ", "拖拽气泡"); } @override public void onmove() { log.e("---> ", "移动气泡"); } @override public void onrestore() { log.e("---> ", "气泡恢复原来位置"); } @override public void ondismiss() { log.e("---> ", "气泡消失"); } });
总结
这次既练习了自定义view,还囊括了贝赛尔曲线,坐标的计算一定要画图,简单直观。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。