在RecyclerView中集成QQ汽泡二

上次已经将GooView集成到RecyclerView当中了【http://www.cnblogs.com/webor2006/p/7787511.html】,但是目前还有很多问题,下面先来运行看一下存在的问题:

如上图所示:当点击汽泡显示咱们的GooView时,拖着它横向移动当前的GooView不响应事件,且拖着向上或向下移动时,居然列表在动,而GooView没有动,很明显这个移动的事件被RecyclerView所截获了,也就是常见的滑动冲突问题,可以扫视一下RecyclerView对事件处理的源代码:

而我们也给汽泡的TexView上添加了触摸事件,并且也返回true:

那为什么其结果是RecyclerView获得了滑动事件的处理权,而我们的TextView没有获取到呢?要解决这个问题,就得对Android的事件分发机制比较了解,所以接下来会从源码级别深入的剖析其原理,当剖析清楚之后对于这个问题就迎刃而解啦,下面开始:

事件分发原理:

①、画图阐述事件分发的一些基本概念【纯概念,但是非常重要!】

当用户触摸屏幕的时候,首先对于Android程序来说,是触摸到了Activity:

而Activity会绑定一个布局,所以这时就会触摸到它的一个根布局:

而对应咱们的例子就是:

接着根布局里面是由我们的普通布局【例如:四大布局】组成,所以又触摸到了普通布局:

而如我们的工程:

最后就是最里面的View啦:

如显示在RecyclerView里面的汽泡TextView:

而对于事件传递需要明确【之后会用代码来论证】:它是由外向内传递的,传递后如果内层没有控件接收,会再由内向外层传递,如果最终没有任何控件处理,则会交由Activity最终处理事件

而我们知道Activity的最外层布局其实是DecorView,也就是FrameLayout,所以图得修改一下:

也就是对于事件传递由外到内会是这样:原始的最外层FrameLayout(DecorView)->根布局(ViewGroup)->普通布局(ViewGroup)->普通的控件视图(View) 

另外还需要牢记一些基本概念:

a、事件是需要传递的,而dispatchTouchEvent()方法就能让事件进行传递。

b、onTouchEvent()一般用于处理触摸事件。

c、onInterceptTouchEvent()是用于拦截触摸事件。

d、requestDisAllowedInterceptTouchEvent()是用于请求父布局不要拦截事件。

关于上面的关系这里先不用纠结,先明确概念,在下面会逐步一一去阐述滴。

②、事件分发的形象案例引入

这里引用一个形象案例对上面的概念进行进一步的阐述,以百年来流行的孔融让梨的故事为例。当然这里只是借其义对事件分发进行阐述,并非跟典故的故事一样,但是跟事件分发的原理是一致的,费话不多说,下面开始:

孔融家族有个传统:大人拿到之后如果不是迫切想吃则会让给他的直接孩子吃,如果大人迫切想要吃这时孩子可以要求父亲不要和我抢,首先先划分角色:

而其中梨扮演事件分发中的MotionEvent事件、是否迫切想要吃代表onInterceptTouchEvent()返回true、孩子想吃梨代表onTouchEvent()返回true、让父亲不要和我抢代表requestDisallowedInterceptTouchEvent()返回true,明确了这些对应关系之后,下面将列出几种场景,也是接下来会按这些场景去分析源码的依据,如下:

③、从源代码角度客观的论证

孔融家的传统:

之前已经说过孔融家族有个传递:拿到梨会先问下他的孩子要不要吃,那对于android中的事件分发也遵循这个传统么,下面来分析下事件分发的源代码就可知晓啦:

当我们用手指去触摸屏幕的时候,首先会触发Activity的dispatchTouchEvent()进行事件传递,也就如案例中的"传梨",如源代码所示:

为了更于接下来一系列的集中分析,将涉及到的关键代码从源代码中单独出来:

在它的dispatchTouchEvent中,首先去调用getWindow()的superDispatchTouchEvent(),而我们知道getWindow()就是DecorView,所以焦点转移到了DecorView的superDispatchTouchEvent()身上:

而也像Activity一样,将它关键代码弄出来,其它一些不想干的代码统统去掉以勉混淆视听,如下:

可以看到它是调用父类的dispatchTouchEvent(),而DecorView的父类是FrameLayout,那就去FrameLayout去找该方法喽,如下:

此处代码甚多,所以还是将关键代码拎出来:

 

精简的代码如下:

为了更好的去理解,所以说下面将案例中的角色也先定义好,如下:

可以看到实际就是一个递归的过程,不断去询问自己的孩子是否要处理该事件【也就是吃梨】,是不是可以看到跟孔融家的传统相符合,当然上面中还差一个角色,那就是孔融本人,这时看一下父亲的dispatchTouchEvent()方法:

同样对它进行精简:

从源码的初步过程就可以进一步对孔融家族的传统有更深的认识啦。

场景一:

对于这个场景,吃不吃梨是由onTouchEvent()来决定,之前也已经描述过,如下:

如果onTouchEvent返回true则表示想要吃,否则不想,所以从这个场景可以看出所有人都选择不吃,也就是所有人的onTouchEvent都返回false,而事件首先是从外向外分发,一直分发到最里面的View,也就是孔融:

这时事件又会从内往外进行分发,所以这时事件就回到了孔融爸爸这了:

不过目前截取的代码太过简单,为了分析这个场景的过程则需要更加详细的源码了,如下:

class ViewGroup {
    View mTarget=null;//保存捕获Touch事件处理的View
    public boolean dispatchTouchEvent(MotionEvent ev) {

        //....其他处理,在此不管
        
        if(ev.getAction()==KeyEvent.ACTION_DOWN){
            //每次Down事件,都置为Null

            if (disallowIntercept || !onInterceptTouchEvent(ev)) {
            mTarget=null;
            View[] views=getChildView();
            for(int i=0;i<views.length;i++){
                if(views[i].dispatchTouchEvent(ev)){
                    mTarget=views[i];
                    return true;
                }
            }
          }
        }
        //当子View没有捕获down事件时,ViewGroup自身处理。这里处理的Touch事件包含Down、Up和Move
        if(mTarget==null){
            return super.dispatchTouchEvent(ev);
        }
        if (!disallowIntercept && onInterceptTouchEvent(ev)) {
            //其他操作,不管
            
            // clear the target
            mTarget = null;
            // Don't dispatch this event to our own view, because we already
            // saw it when intercepting; we just want to give the following
            // event to the normal onTouchEvent().
            return true;
        }
        //这一步在Action_Down中是不会执行到的,只有Move和UP才会执行到。
        return mTarget.dispatchTouchEvent(ev);

    }
}

分析如下:

而super.dispatchTouchEvent()其实就是ViewGroup的super,那当然是View.dispatchTouchEvent()啦,而由于孔融爸爸也选择不吃,那它当然也返回false啦,则整个孔融爸爸的事件又往外传递,这时就会传递给孔融爷爷啦,而同样的也得弄详细一点的源码过行分析,代码跟孔融爸爸一样,这时执行逻辑也跟孔融爸爸是一模一样的,所以:

这时事件就传递到了孔融祖先这啦,其代码流程跟孔融爷爷是一模一样,直接给出结果:

好了DOWN事件整个由内到外的过程就已经结束了,接着来MOVE新事件,则又会遵循由外向内传递,又由内往外传递的原则,所以这时事件又会由Activity传到DecorView,接着由DecorView到它的ViewGroup,这时看一下MOVE事件是如何执行的:

而super就会转到View.dispatchTouchEvent(),由于它不吃也返回false,所以事件到了孔融祖先这就不向下传递了,而直接由内往外传递,这时又回到了Activity的ouTouchEvent()了,而未来会有N个MOVE事件到来,最终全是被Activity的onTouchEvent给消费掉了,而孔融家族的人一个都没有吃到梨,所以这也就从源代码上来论证了场景一所得出的结果。 

场景二:

有了场景一的分析经验,对于这个场景相比第一个场景而言这次孔融选择要吃梨了,DOWN事件最终会由Activity一步步由外向里传递给最里面的View也就是孔融,而此时它返回值发生了变化,如下:

这时事件又会由最里层往外进行分发,所以会回到孔融爸爸这:

这时事件又到了孔融爷爷,其过程跟孔爸爸类似,再由孔融爷爷又到孔融祖先这,这时跟孔爷爷类似,最终都确立了mTarget对象,最终将事件又传回到了Activity,这时看一下发生了啥变化 :

好了,接着MOVE新事件到来,则会由Activity传给孔融祖先,这时:

以此类推,又会传给孔融爸爸,最终传到了孔融,也就是最终的所有事件都是由孔融一个人消费掉了,也论证了这个场景的推论。

知道了这个场景之后,那之前在博文中说到的这点就可以理解啦【http://www.cnblogs.com/webor2006/p/7705085.html】:

场景三:

根据场景二的情况,在孔融想吃时在DOWN是给每个人确定了目标,而基于它这次孔融爸爸也选择想吃了,那为啥吃到梨的人最终还是它的孩子孔融呢?其实比较容易理解,分析如下:

①、在DOWN事件时都是问孩子吃不吃,这是孔融家的传统,所以最终由孔融消费掉。并且确定了每个人的目标。

②、在MOVE事件时,由于目标已经确立,所以:

那类似的,最终传到了孔融爸爸,虽说这次他选择想吃了,但是由于都会交给他的目标也就是他的孩子孔融吃,所以压根就没有进入孔融爸爸的onTouchEvent()方法,如下:

所以结果如我们所预料的,最终还是由其孔融吃到梨,从这个场景可以得出一个结论:

onTouchEvent()=true仅仅表示想要处理,但是不是迫切需要处理,当有多个视图的onTouchEvent()返回true,则事件优化交给子视图处理。

场景四:

继续结合场景三所分析的,这次孔融爸爸迫切想要吃了,其中的迫切想要吃是指它的onInterceptTouchEvent返回true,所以还是按如下分析:

①、在DOWN事件时都是问孩子吃不吃,这是孔融家的传统,到了爸爸这,这时就有变化啦,如下:

这时祖先的目标是爷爷,爷爷的目标是爸爸,但是爸爸没有目标。

②、在MOVE事件时,由于祖先、爷爷目标已经确立,所以事件直接就传到了爸爸这了,这时由于爸爸没有确立目标,所以:

之后的事件全是由爸爸自己消费掉啦。

场景五:

最后一种场景了,继续结合场景四,这里孔融在吃的同时并请求父亲不要和我抢,而请求父亲不要和我抢其实就是requestDisallowInterceptTouchEvent(true),由于增加了一个新的api调用,那咱们的伪代码也得适当整完善一些,所以:

而此时ViewGroup代码则需要增加此方法的处理,如下:

class ViewGroup {
    View mTarget=null;//保存捕获Touch事件处理的View
    public boolean dispatchTouchEvent(MotionEvent ev) {

        //....其他处理,在此不管
        
        if(ev.getAction()==KeyEvent.ACTION_DOWN){
            //每次Down事件,都置为Null

            if (disallowIntercept || !onInterceptTouchEvent(ev)) {
            mTarget=null;
            View[] views=getChildView();
            for(int i=0;i<views.length;i++){
                if(views[i].dispatchTouchEvent(ev)){
                    mTarget=views[i];
                    return true;
                }
            }
          }
        }
        //当子View没有捕获down事件时,ViewGroup自身处理。这里处理的Touch事件包含Down、Up和Move
        if(mTarget==null){
            return super.dispatchTouchEvent(ev);
        }
        if (!disallowIntercept && onInterceptTouchEvent(ev)) {
            //其他操作,不管
            
            // clear the target
            mTarget = null;
            // Don't dispatch this event to our own view, because we already
            // saw it when intercepting; we just want to give the following
            // event to the normal onTouchEvent().
            return true;
        }
        //这一步在Action_Down中是不会执行到的,只有Move和UP才会执行到。
        return mTarget.dispatchTouchEvent(ev);

    }

    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

        if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
            // We're already in this state, assume our ancestors are too
            return;
        }

        if (disallowIntercept) {
            mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
        } else {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        }

        // Pass it up to our parent
        if (mParent != null) {
            mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
        }
    }
}

接下来还是按如下两种情况来对这种场景进行分析:

①、在DOWN事件时都是问孩子吃不吃,这是孔融家的传统,所以最终传到爸爸这,由于爸爸迫切想要吃,照上一个场景来说照理是不会将事件传给孔融,但是!!可以先看一下代码:

而由于孔融执行了requestDisallowInterceptTouchEvent(true),这时它会将它的父类也就是孔融爸爸的disallowIntercept变为true,这时目标就可以确立了,也就是交由孔融进行事件处理啦。

②、在MOVE事件时,由于目标已经确立,所以最终事件全交由孔融来处理这事了,这里好好体会下requestDisallowInterceptTouchEvent(true)的作用,它将会用在下面解决GooView的RecyclerView事件冲突解决上面。

总结:

1、默认事件处理都会询问它的孩子是否要处理。

2、DOWN时确定目标。

3、事件由外向内传递,并又由内往外分发。

4、是否要处理事件由onTouchEvent返回true决定,而它只是表示想要处理,但是事件是否真正的由它来处理得结合具体场景,不一定。

5、是否要迫切需要处理事件由onInterceptTouchEvent返回true来决定。

6、当父视图想要拦截事件时,它的子视图可以通过getParent().requestDisallowInterceptTouchEvent(true)禁止父视图拦截事件。

解决GooView在RecyclerView中的事件冲突问题:

上面用了大量篇幅来阐述事件分发原理也就是为了来解决咱们的实际问题的,问题在开篇就已经阐述清楚了,等于触摸GooView时最终的事件被RecyclerView所拦截了,而RecyclerView中的onTouchEvent()做了非常多的逻辑判断,所以要解决这个问题不可能去分析清楚它的源码然后再动手解决,这里还是从事件分发的原理着手,而事件分发的原理首要的就是要分析清楚View的层级关系,如咱们分析的孔融爷爷、爸爸、孩子等,只有清楚了层次嵌套关系这样解决问题才比较顺其自然,所以下面先来看一下目前咱们界面的层级关系:

而问题的核心就是事件被孔融爷爷处理了,而没有传到孔融爸爸手上,当然也不可能传给孔融手上啦,而由于TextView的事件得不到处理,那咱们的GooView滑动就会出问题啦,所以解决思路就是:想办法让事件传递到孔融爸爸手上,也就是咱们的条目LinearLayout,那确认是事件被RecyclerView给拦截了么,下面来认证一下,为了方便打印直接新建一个类继承至RecyclerView,如下:

然后这时在MainActivity中用咱们的这个类:

这时运行看一下打印:

而解决的办法就是不让RecyclerView拦截事件,如果搞懂了场景四的原理,那达到这样的目的那不要太简单哦,所以说干就干:

而处理就是在这监听里处理,如下:

这时再运行:

很明显在左右上下滑动时RecyclerView并没有进行滑动,也就是我们成功的处理的最关键的一步:不让RecyclerView拦截事件。

但是!!现在的GooView还无法处理咱们的滑动事件,还是存在问题,那如何解决呢,这就比较简单啦,代码如下:

编译运行:

完美解决,但是还是有新的bug的,如当拉断之后需要将GooView消失等,这个会在后面进行处理。

在继续完善代码之前,插一个小知识点来解释之前解释的一个现象【http://www.cnblogs.com/webor2006/p/7787511.html】:

那这里从源码角度来解释一下为什么,这时就得将View的dispatchTouchEvent()的伪代码得完善一下啦,如下:

其中:

所以这就是原因。

GooView的消失和重置处理:

接下来看一下这种现象:

居然拉断之后咱们的GooView木有消失,当时咱们在自定义GooView是有处理拉断消失的情况的呀,这是为啥呢?

这是由于咱们是采用WindowManager来实现的,在显示时是addView的方式添加到WindowManager上,而想要消失则需要通过WindowManager.removeView才行,而何时去调用这个移除方法呢,这时得给GooView增加一个方法回调,当我们松开手时则回调它,如下:

/**
 * Goo:是粘性的意思,QQ汽泡效果
 * 增加回调方法,比如拉断时
 */
public class GooView extends View {

    /* 拖拽圆移动的最大距离 */
    private static final float MAX_DRAG_DISTANCE = 200f;
    /* 固定圆缩放的最小半径 */
    private static final float MIN_STABLE_RADIUS = 5f;

    private Paint paint;
    /* 固定圆的圆心 */
    private PointF stableCenter = new PointF(200f, 200f);
    /* 固定圆的半径 */
    private float stableRadius = 20f;
    /* 固定圆的两个附着点 */
    private PointF[] stablePoints;
    /* 拖拽圆的圆心 */
    private PointF dragCenter = new PointF(100f, 100f);
    /* 拖拽圆的半径 */
    private float dragRadius = 20f;
    /* 拖拽圆的两个附着点 */
    private PointF[] dragPoints;
    /* 绘制中间不规则的路径 */
    private Path path;
    /* 贝塞尔曲线的控制点 */
    private PointF controlPoint;
    /* 状态栏高度 */
    private int statusBarHeight;
    /* 是否拖拽已经超出最大范围了,超出则不绘制拖拽圆和中间图形实现拉断效果 */
    private boolean isOutOfRange = false;
    /* 是否全部消失,如果true则所有图形消失 */
    private boolean isDisappear = false;
    /* 在拖拽圆上显示的文本,由外部传进来 */
    private String text;
    /* 用来计算文本宽度的"空壳"矩形 */
    private Rect textRect;
    /* 监听回调 */
    private OnGooViewChangeListener onGooViewChangeListener;

    public void setOnGooViewChangeListener(OnGooViewChangeListener onGooViewChangeListener) {
        this.onGooViewChangeListener = onGooViewChangeListener;
    }

    public GooView(Context context) {
        this(context, null);
    }

    public GooView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public GooView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        paint = new Paint();
        paint.setAntiAlias(true);
        paint.setColor(Color.RED);
        paint.setTextSize(25);

        path = new Path();

        textRect = new Rect();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.save();
        canvas.translate(0, -statusBarHeight);//优雅的解决拖拽位置不是在圆心的问题,不用在触摸事件中单独处理了

        //处理随着拖拽圆和固定圆的圆心距离越来越大,固定圆的半径越来越小:拖拽圆和固定圆圆心的距离百分比变化==固定圆半径的百分比变化
        float distance = GeometryUtil.getDistanceBetween2Points(dragCenter, stableCenter);//获得两圆心的距离
        float percent = distance / MAX_DRAG_DISTANCE;//计算百分比
        float tempRadius = GeometryUtil.evaluateValue(percent, stableRadius, MIN_STABLE_RADIUS);//根据百分比来动态计算出固定圆的半径
        if (tempRadius < MIN_STABLE_RADIUS)//处理最小半径
            tempRadius = MIN_STABLE_RADIUS;

        if (!isDisappear) {
            if (!isOutOfRange) {//只有没有超出范围才绘制固定圆和中间图形

                //绘制中间图形,直接基于两圆来绘制,其步骤为:
                //一、基于两圆来计算真实的附着点与曲线的控制点
                float dx = dragCenter.x - stableCenter.x;
                float dy = dragCenter.y - stableCenter.y;
                double linek = 0;
                if (dx != 0) {//被除数不能为0,防止异常
                    linek = dy / dx;
                }
                //得到实际的附着点
                dragPoints = GeometryUtil.getIntersectionPoints(dragCenter, dragRadius, linek);
                stablePoints = GeometryUtil.getIntersectionPoints(stableCenter, tempRadius, linek);
                controlPoint = GeometryUtil.getMiddlePoint(dragCenter, stableCenter);

                //二、开始绘制中间图形
                // 1、移动到固定圆的附着点1;
                path.moveTo(stablePoints[0].x, stablePoints[0].y);
                // 2、向拖拽圆附着点1绘制贝塞尔曲线;
                path.quadTo(controlPoint.x, controlPoint.y, dragPoints[0].x, dragPoints[0].y);
                // 3、向拖拽圆的附着点2绘制直线;
                path.lineTo(dragPoints[1].x, dragPoints[1].y);
                // 4、向固定圆的附着点2绘制贝塞尔曲线;
                path.quadTo(controlPoint.x, controlPoint.y, stablePoints[1].x, stablePoints[1].y);
                // 5、闭合;
                path.close();
                canvas.drawPath(path, paint);
                path.reset();//解决滑动时路径重叠的问题

                ////绘制固定圆
                canvas.drawCircle(stableCenter.x, stableCenter.y, tempRadius, paint);
            }
            //绘制拖拽圆
            canvas.drawCircle(dragCenter.x, dragCenter.y, dragRadius, paint);
            drawText(canvas);
        }
        canvas.restore();
    }

    /**
     * 绘制文本
     */
    private void drawText(Canvas canvas) {
        if (TextUtils.isEmpty(this.text))
            return;
        paint.setColor(Color.WHITE);//将文本颜色设置为白色
        //获得文本的边界:原理是将文本套入一个"完壳"矩形,这个矩形的宽高是文本的距离
        paint.getTextBounds(this.text, 0, text.length(), textRect);
        float x = dragCenter.x - textRect.width() * 0.5f;//为拖拽圆圆心横坐标 - 文本宽度 * 0.5f
        float y = dragCenter.y + textRect.height() * 0.5f;//为拖拽圆圆心纵坐标 + 文本高度 * 0.5f
        canvas.drawText(this.text, x, y, paint);
        paint.setColor(Color.RED);//将其颜色还原
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                isOutOfRange = false;//方便调试
                isDisappear = false;//方便调试
                //event.getX();//点击的点距离当前自定义控件的左边缘的距离
                float rawX = event.getRawX();//点击的点距离当前手机屏幕的左边缘的距离
                float rawY = event.getRawY()/* - statusBarHeight*/;
                dragCenter.set(rawX, rawY);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                rawX = event.getRawX();
                rawY = event.getRawY();
                dragCenter.set(rawX, rawY);
                //当拖拽超出一定范围后,固定圆和中间图形都消失了,其"消失了"用程序来说就是在onDraw()中不绘制某一段了
                //判断拖拽的距离,判断距离是否超出最大距离
                float distance = GeometryUtil.getDistanceBetween2Points(stableCenter, dragCenter);
                if (distance > MAX_DRAG_DISTANCE) {
                    //当超出最大距离则不再对固定圆和中间图形进行绘制
                    isOutOfRange = true;
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                distance = GeometryUtil.getDistanceBetween2Points(stableCenter, dragCenter);
                //判断在move的时候是否超出过最大范围
                if (isOutOfRange) {
                    //判断up的时候是否在最大范围外
                    if (distance > MAX_DRAG_DISTANCE) {
                        isDisappear = true;
                        if (onGooViewChangeListener != null)
                            onGooViewChangeListener.onDisappear();
                    } else {
                        //up未超出最大范围,则将拖拽圆的圆心设置成固定圆圆心
                        dragCenter.set(stableCenter.x, stableCenter.y);
                    }
                } else {
                    //move、up均未超出最大范围这时得有一个回弹还原效果:从拖拽抬手处到固定圆圆心之间来一个顺间平移动画,当到达固定圆
                    //圆心之后回弹一下
                    final PointF tempPointF = new PointF(dragCenter.x, dragCenter.y);//需要将up的瞬间拖拽圆的坐标记录下来以便进行平移动画
                    ValueAnimator va = ValueAnimator.ofFloat(distance, 0);
                    va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                        @Override
                        public void onAnimationUpdate(ValueAnimator animation) {
//                            float animatedValue = (float) animation.getAnimatedValue();//变化的具体值
                            float percent = animation.getAnimatedFraction();//变化的百分比
                            dragCenter = GeometryUtil.getPointByPercent(tempPointF, stableCenter, percent);
                            invalidate();
                        }
                    });
                    //动画插值器来实现回弹效果,然后回到原位
//                    va.setInterpolator(new OvershootInterpolator());
                    va.setInterpolator(new OvershootInterpolator(3));//其中回弹多少可以传参控制
                    va.setDuration(500);
                    va.start();
                }
                invalidate();
                break;
        }
        //return super.onTouchEvent(event);
        return true;//表示当前控件想要处理事件
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        statusBarHeight = getStatusBarHeight(this);
    }

    /**
     * 获取状态栏高度
     *
     * @param view
     * @return
     */
    private int getStatusBarHeight(View view) {
        Rect rect = new Rect();
        //获取视图对应的可视范围,会把视图的左上右下的数据传入到一个矩形中
        view.getWindowVisibleDisplayFrame(rect);
        return rect.top;
    }

    public void setText(String text) {
        this.text = text;
    }

    /**
     * 初始化坐标
     */
    public void initGooViewPosition(float x, float y) {
        //将拖拽圆和固定圆的坐标全部设置为参数对应的坐标
        stableCenter.set(x, y);
        dragCenter.set(x, y);
        invalidate();
    }

    public interface OnGooViewChangeListener {
        /**
         * 当拉断时回调它
         */
        void onDisappear();
    }
}

然后在OnGooViewTouchListener中添加监听并在监听回调中进行处理,如下:

public class OnGooViewTouchListener implements View.OnTouchListener, GooView.OnGooViewChangeListener {

    private TextView tv_unReadMsgCount;
    /*它可以在任何的界面的情况下添加一个额外的视图*/
    private final WindowManager manager;
    private final WindowManager.LayoutParams params;
    private final GooView gooView;

    public OnGooViewTouchListener(Context context) {
        manager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        //这里面的参数可以参考Toast的源码
        params = new WindowManager.LayoutParams();
        params.height = WindowManager.LayoutParams.MATCH_PARENT;
        params.width = WindowManager.LayoutParams.MATCH_PARENT;
        params.format = PixelFormat.TRANSLUCENT;

        gooView = new GooView(context);
        gooView.setOnGooViewChangeListener(this);
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        //获取TextView控件的父布局既条目的根布局,让RecyclerView不能拦截事件
        v.getParent().requestDisallowInterceptTouchEvent(true);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //点击时则让文本隐藏
                tv_unReadMsgCount = (TextView) v;
                tv_unReadMsgCount.setVisibility(View.INVISIBLE);

                //初始化文本及坐标
                float rawX = event.getRawX();
                float rawY = event.getRawY();
                gooView.initGooViewPosition(rawX, rawY);
                gooView.setText(tv_unReadMsgCount.getText().toString());
                //添加gooview来代替文本
                manager.addView(gooView, params);
                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        gooView.onTouchEvent(event);
        //表示自己想要处理事件
        return true;
    }

    @Override
    public void onDisappear() {
        manager.removeView(gooView);
    }
}

这时再编译运行:

嗯~~~完美解决~~但是!再看一个问题:

当未达到最大距离回弹回来时,有木有发现这个圆偏大,这是由于咱们的GooView并未从WindowManager中移除掉,其思路跟断开的处理一样,先在GooView中增加监听,这个监听触发是在回弹动画结束之后,所以需要给回弹动画增加一个动画监听:

但是这个监听方法太多了,貌似用得到的就只有动画结束,代码有些冗余,那有没有更优的写法呢?当然有如下:

然后再在回调接口中增加一个回调方法,并在这个动画结束之后调用它,如下:

public class GooView extends View {

    /* 拖拽圆移动的最大距离 */
    private static final float MAX_DRAG_DISTANCE = 200f;
    /* 固定圆缩放的最小半径 */
    private static final float MIN_STABLE_RADIUS = 5f;

    private Paint paint;
    /* 固定圆的圆心 */
    private PointF stableCenter = new PointF(200f, 200f);
    /* 固定圆的半径 */
    private float stableRadius = 20f;
    /* 固定圆的两个附着点 */
    private PointF[] stablePoints;
    /* 拖拽圆的圆心 */
    private PointF dragCenter = new PointF(100f, 100f);
    /* 拖拽圆的半径 */
    private float dragRadius = 20f;
    /* 拖拽圆的两个附着点 */
    private PointF[] dragPoints;
    /* 绘制中间不规则的路径 */
    private Path path;
    /* 贝塞尔曲线的控制点 */
    private PointF controlPoint;
    /* 状态栏高度 */
    private int statusBarHeight;
    /* 是否拖拽已经超出最大范围了,超出则不绘制拖拽圆和中间图形实现拉断效果 */
    private boolean isOutOfRange = false;
    /* 是否全部消失,如果true则所有图形消失 */
    private boolean isDisappear = false;
    /* 在拖拽圆上显示的文本,由外部传进来 */
    private String text;
    /* 用来计算文本宽度的"空壳"矩形 */
    private Rect textRect;
    /* 监听回调 */
    private OnGooViewChangeListener onGooViewChangeListener;

    public void setOnGooViewChangeListener(OnGooViewChangeListener onGooViewChangeListener) {
        this.onGooViewChangeListener = onGooViewChangeListener;
    }

    public GooView(Context context) {
        this(context, null);
    }

    public GooView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public GooView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        paint = new Paint();
        paint.setAntiAlias(true);
        paint.setColor(Color.RED);
        paint.setTextSize(25);

        path = new Path();

        textRect = new Rect();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.save();
        canvas.translate(0, -statusBarHeight);//优雅的解决拖拽位置不是在圆心的问题,不用在触摸事件中单独处理了

        //处理随着拖拽圆和固定圆的圆心距离越来越大,固定圆的半径越来越小:拖拽圆和固定圆圆心的距离百分比变化==固定圆半径的百分比变化
        float distance = GeometryUtil.getDistanceBetween2Points(dragCenter, stableCenter);//获得两圆心的距离
        float percent = distance / MAX_DRAG_DISTANCE;//计算百分比
        float tempRadius = GeometryUtil.evaluateValue(percent, stableRadius, MIN_STABLE_RADIUS);//根据百分比来动态计算出固定圆的半径
        if (tempRadius < MIN_STABLE_RADIUS)//处理最小半径
            tempRadius = MIN_STABLE_RADIUS;

        if (!isDisappear) {
            if (!isOutOfRange) {//只有没有超出范围才绘制固定圆和中间图形

                //绘制中间图形,直接基于两圆来绘制,其步骤为:
                //一、基于两圆来计算真实的附着点与曲线的控制点
                float dx = dragCenter.x - stableCenter.x;
                float dy = dragCenter.y - stableCenter.y;
                double linek = 0;
                if (dx != 0) {//被除数不能为0,防止异常
                    linek = dy / dx;
                }
                //得到实际的附着点
                dragPoints = GeometryUtil.getIntersectionPoints(dragCenter, dragRadius, linek);
                stablePoints = GeometryUtil.getIntersectionPoints(stableCenter, tempRadius, linek);
                controlPoint = GeometryUtil.getMiddlePoint(dragCenter, stableCenter);

                //二、开始绘制中间图形
                // 1、移动到固定圆的附着点1;
                path.moveTo(stablePoints[0].x, stablePoints[0].y);
                // 2、向拖拽圆附着点1绘制贝塞尔曲线;
                path.quadTo(controlPoint.x, controlPoint.y, dragPoints[0].x, dragPoints[0].y);
                // 3、向拖拽圆的附着点2绘制直线;
                path.lineTo(dragPoints[1].x, dragPoints[1].y);
                // 4、向固定圆的附着点2绘制贝塞尔曲线;
                path.quadTo(controlPoint.x, controlPoint.y, stablePoints[1].x, stablePoints[1].y);
                // 5、闭合;
                path.close();
                canvas.drawPath(path, paint);
                path.reset();//解决滑动时路径重叠的问题

                ////绘制固定圆
                canvas.drawCircle(stableCenter.x, stableCenter.y, tempRadius, paint);
            }
            //绘制拖拽圆
            canvas.drawCircle(dragCenter.x, dragCenter.y, dragRadius, paint);
            drawText(canvas);
        }
        canvas.restore();
    }

    /**
     * 绘制文本
     */
    private void drawText(Canvas canvas) {
        if (TextUtils.isEmpty(this.text))
            return;
        paint.setColor(Color.WHITE);//将文本颜色设置为白色
        //获得文本的边界:原理是将文本套入一个"完壳"矩形,这个矩形的宽高是文本的距离
        paint.getTextBounds(this.text, 0, text.length(), textRect);
        float x = dragCenter.x - textRect.width() * 0.5f;//为拖拽圆圆心横坐标 - 文本宽度 * 0.5f
        float y = dragCenter.y + textRect.height() * 0.5f;//为拖拽圆圆心纵坐标 + 文本高度 * 0.5f
        canvas.drawText(this.text, x, y, paint);
        paint.setColor(Color.RED);//将其颜色还原
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                isOutOfRange = false;//方便调试
                isDisappear = false;//方便调试
                //event.getX();//点击的点距离当前自定义控件的左边缘的距离
                float rawX = event.getRawX();//点击的点距离当前手机屏幕的左边缘的距离
                float rawY = event.getRawY()/* - statusBarHeight*/;
                dragCenter.set(rawX, rawY);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                rawX = event.getRawX();
                rawY = event.getRawY();
                dragCenter.set(rawX, rawY);
                //当拖拽超出一定范围后,固定圆和中间图形都消失了,其"消失了"用程序来说就是在onDraw()中不绘制某一段了
                //判断拖拽的距离,判断距离是否超出最大距离
                float distance = GeometryUtil.getDistanceBetween2Points(stableCenter, dragCenter);
                if (distance > MAX_DRAG_DISTANCE) {
                    //当超出最大距离则不再对固定圆和中间图形进行绘制
                    isOutOfRange = true;
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                distance = GeometryUtil.getDistanceBetween2Points(stableCenter, dragCenter);
                //判断在move的时候是否超出过最大范围
                if (isOutOfRange) {
                    //判断up的时候是否在最大范围外
                    if (distance > MAX_DRAG_DISTANCE) {
                        isDisappear = true;
                        if (onGooViewChangeListener != null)
                            onGooViewChangeListener.onDisappear();
                    } else {
                        //up未超出最大范围,则将拖拽圆的圆心设置成固定圆圆心
                        dragCenter.set(stableCenter.x, stableCenter.y);
                        //做重置操作
                        if(onGooViewChangeListener != null)
                            onGooViewChangeListener.onReset();
                    }
                } else {
                    //move、up均未超出最大范围这时得有一个回弹还原效果:从拖拽抬手处到固定圆圆心之间来一个顺间平移动画,当到达固定圆
                    //圆心之后回弹一下
                    final PointF tempPointF = new PointF(dragCenter.x, dragCenter.y);//需要将up的瞬间拖拽圆的坐标记录下来以便进行平移动画
                    ValueAnimator va = ValueAnimator.ofFloat(distance, 0);
                    va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                        @Override
                        public void onAnimationUpdate(ValueAnimator animation) {
//                            float animatedValue = (float) animation.getAnimatedValue();//变化的具体值
                            float percent = animation.getAnimatedFraction();//变化的百分比
                            dragCenter = GeometryUtil.getPointByPercent(tempPointF, stableCenter, percent);
                            invalidate();
                        }
                    });
                    //动画插值器来实现回弹效果,然后回到原位
//                    va.setInterpolator(new OvershootInterpolator());
                    va.setInterpolator(new OvershootInterpolator(3));//其中回弹多少可以传参控制
                    va.addListener(new AnimatorListenerAdapter() {
                        @Override
                        public void onAnimationEnd(Animator animation) {
                            //做重置操作
                            if(onGooViewChangeListener != null)
                                onGooViewChangeListener.onReset();
                        }
                    });
                    va.setDuration(500);
                    va.start();
                }
                invalidate();
                break;
        }
        //return super.onTouchEvent(event);
        return true;//表示当前控件想要处理事件
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        statusBarHeight = getStatusBarHeight(this);
    }

    /**
     * 获取状态栏高度
     *
     * @param view
     * @return
     */
    private int getStatusBarHeight(View view) {
        Rect rect = new Rect();
        //获取视图对应的可视范围,会把视图的左上右下的数据传入到一个矩形中
        view.getWindowVisibleDisplayFrame(rect);
        return rect.top;
    }

    public void setText(String text) {
        this.text = text;
    }

    /**
     * 初始化坐标
     */
    public void initGooViewPosition(float x, float y) {
        //将拖拽圆和固定圆的坐标全部设置为参数对应的坐标
        stableCenter.set(x, y);
        dragCenter.set(x, y);
        invalidate();
    }

    public interface OnGooViewChangeListener {
        /**
         * 当拉断时回调它
         */
        void onDisappear();

        /**
         * 当没拉到最大距离回弹动画结束之后回调它
         */
        void onReset();
    }
}

这时我们再OnGooViewTouchListener注册监听的onReset()中进行重置操作:

编译运行:

解决多次点击造成的bug:

看下面这个现象:

呃~~崩溃了,看一下错误日志:

错误出现在这一行:

而这上错误的意思应该是指gooView跟WindowManager没有绑定而执行了移除操作所致,也就是说此时gooView并未添加到WindowManager了,那打个日志来验证下是否是这样:

这时编译运行:

这是正常的情况,只点了一下,add一次,remove一次,接下来复现刚才的bug,连续点击:

而从日志输出来看:

那不正如我们所预想的一样么,那如何修复呢?其实WindowManager.addView(GooView)之后,也就是会将GooView加到一个父布局当中,而如果WindowManager.removeView(GooView)之后,则GooView它就没有父布局了,所以根据这个解决起来也非常简单啦,如下:

这时再看效果:

这时就完美解决了这个bug,到此整个QQ汽泡效果就已经完全剖析完了,说实话还是蛮复杂的,不过可能存在ListView滑动状态保持的一些bug,这里就不细说了,重点还是如何在列表中去集成qq汽泡,需好好消化。

posted on 2017-11-19 17:15  cexo  阅读(243)  评论(0编辑  收藏  举报

导航