史上最详细仿QQ未读消息拖拽粘性效果的实现

好久没写文章了,前段时间由于项目代码重构忙了一段时间,现在终于有点时间了就为大家带来一篇关于动画学习的自定义View:类似QQ消息拖拽的效果。

其实QQ当时更新的时候我还没注意到这个小红点是可以拖拽的,后来无意间发现之后就把玩了好久,当时就感觉这个效果还挺好玩的,曾经有过一个念头去实现一个这样的效果,中间由于种种原因一直没去做,今天就算是对过去承诺的兑现吧。

其实网上已经有很多这样的资料了,也有现成的demo,但大部分讲解的不够详细,很多计算都只是列个公式画个草图一笔带过,对于我们这些数学不好的人来说有点懵逼,好了,话不多说本篇文章将向你对中间的计算过程讲的明明白白的。

开始之前我建议大家打开QQ先去熟悉一下这个拖拽效果,然后根据自己掌握的知识梳理一下自己去实现的思路,包括中间粘性效果的实现。
按照惯例,先看看本篇文章能实现的最终效果

最终效果

我来分析一下我对这个实现过程的理解:首先是在指定某个位置画一个圆出来,手指按到这个圆的时候再绘制一个可以根据手指位置移动的圆,随着手指的移动两个圆逐渐分离,分离的过程中两圆中间出现连接带,随着两圆圆心距的增大,半径也是根据某一比例系数扩大或缩小,当超过临界点的时候起始圆消失,只剩手指所在位置的圆,然后手指松开圆消失。


根据上面的分析我们得出绘制步骤:
1、在指定位置绘制起始圆(圆中间可以带数字)
2、使用贝塞尔曲线绘制两圆之间的连接带
3、处理onTouchEvent事件(down、move、up)
4、添加一些动画特效


下面我们就按照上述步骤开始撸代码

1、绘制起始圆

当然我们要实现定义一些常量,画笔等的初始化代码我就不再展示了

    //是否可拖拽
    private boolean mIsCanDrag = false;
    //是否超过最大距离
    private boolean isOutOfRang = false;
    //最终圆是否消失
    private boolean disappear = false;

    //两圆相离最大距离
    private float maxDistance;

    //贝塞尔曲线需要的点
    private PointF pointA;
    private PointF pointB;
    private PointF pointC;
    private PointF pointD;
    //控制点坐标
    private PointF pointO;

    //起始位置点
    private PointF pointStart;
    //拖拽位置点
    private PointF pointEnd;

    //根据滑动位置动态改变圆的半径
    private float currentRadiusStart;
    private float currentRadiusEnd;
    
    private Rect textRect = new Rect();
    //消息数
    private int msgCount = 0;

画圆大家应该都不陌生,一行代码搞定,传入圆心坐标,半径,画笔即可

    /**
     * 画起始小球
     *
     * @param canvas 画布
     * @param pointF 点坐标
     * @param radius 半径
     */
    private void drawStartBall(Canvas canvas, PointF pointF, float radius) {
        canvas.drawCircle(pointF.x, pointF.y, radius, circlePaint);
    }

    /**
     * 画拖拽结束的小球
     *
     * @param canvas 画布
     * @param pointF 点坐标
     * @param radius 半径
     */
    private void drawEndBall(Canvas canvas, PointF pointF, float radius) {
        canvas.drawCircle(pointF.x, pointF.y, radius, circlePaint);
    }

初始化一些常量,我们demo演示以屏幕中心为圆心

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        startX = w / 2;
        startY = h / 2;
        maxDistance = dp2px(100);
        radiusStart = dp2px(15);
        radiusEnd = dp2px(15);

        currentRadiusEnd = radiusEnd;
        currentRadiusStart = radiusStart;
    }

这样我们就在屏幕中心处绘制了一个圆

2、根据贝塞尔曲线绘制连接带

这是本文的重点,计算过程会讲解的非常详细,通俗易懂
我们先看下画出了是什么样的再去分析

大概是这样的效果

两个圆我们知道怎么画的了,现在就来分析一下连接带的实现,可以看到是两段平滑的过渡,这样的弧度使用贝塞尔再好不过了,我们在简单回顾一下贝塞尔曲线的样子


二阶贝塞尔曲线.gif

看到这个效果是不是会心一笑,这TM就是我们要的效果
下边看下我画的一个分析图,可以说是目前网上最详细的图文解释了(配上骄傲的表情)

堪称史上最详细的图文解释示意图

注意:图中有一个角度描述错了 tanEAS1应该是tanESS1
由于带撇的点无法在MD语法中标示出来 故用1代替撇,例如A`=A1

为了加深理解我在描述一下图中的意思:
起点圆我们定义为圆S(start的缩写),对应的圆心坐标为S(Sx,Sy),可拖拽圆也就是终点圆定义为圆E(end的缩写),圆心坐标为E(Ex,Ey)。连接带的路径可以从图上看出来是:A-->O-->B-->C-->O-->D-->A,其中O为AOB和COD这两段二阶贝塞尔曲线的控制点,图中绿线标注了五个角度,这五个角度是相等的,可以根据三角形的相关定理得出,为了充分说明我们是史上最详细的解释,我就举个例子说明一下为什么角度相等,数学不错的伙伴可以跳过这段啦,角ASA1+ A1SE=90度=A1SE+ESD1可以推出角ASA1=ESD1,同理可以的出其余标示角度相等,我们定义为角A,后边我们就是根据角度计算各个点的坐标的

已知起点圆心S(Sx,Sy),终点圆心E(Ex,Ey),E就是手指滑动所在的位置,可以根据event.getX()和event.getY()取到

我们以角ESS1为例进行计算:
tanESS1=tanA=S1E/SS1=(Ex-Sx)/(Ey-Sy)=rate,rate就是这个角的斜率,然后根据反正切得出角A,A=arctan(rate),这是反正切公式,忘记的可以去百度百科温故一下哦。
知道了角度A就可以根据角度加上正余弦函数算出各个点的坐标了,这个计算推倒过程我已写在图上了,下边就把上述计算过程用代码实现一下

    /**
     * 设置贝塞尔曲线的相关点坐标  计算方式参照结算图即可看明白
     * (ps为了画个清楚这个图花了不少功夫哦)
     */
    private void setABCDOPoint() {
        //控制点坐标
        pointO.set((pointStart.x + pointEnd.x) / 2.0f, (pointStart.y + pointEnd.y) / 2.0f);

        float x = pointEnd.x - pointStart.x;
        float y = pointEnd.y - pointStart.y;

        //斜率 tanA=rate
        double rate;
        rate = x / y;
        //角度  根据反正切函数算角度
        float angle = (float) Math.atan(rate);

        pointA.x = (float) (pointStart.x + Math.cos(angle) * currentRadiusStart);
        pointA.y = (float) (pointStart.y - Math.sin(angle) * currentRadiusStart);

        pointB.x = (float) (pointEnd.x + Math.cos(angle) * currentRadiusEnd);
        pointB.y = (float) (pointEnd.y - Math.sin(angle) * currentRadiusEnd);

        pointC.x = (float) (pointEnd.x - Math.cos(angle) * currentRadiusEnd);
        pointC.y = (float) (pointEnd.y + Math.sin(angle) * currentRadiusEnd);

        pointD.x = (float) (pointStart.x - Math.cos(angle) * currentRadiusStart);
        pointD.y = (float) (pointStart.y + Math.sin(angle) * currentRadiusStart);
    }

至此关于贝塞尔曲线这部分就介绍完了,下边把圆个弧度代码串联起来就ok了,还费什么话先看看效果咋样,先把终点圆坐标定死在一个位置看下效果,为了方便看到绘制的路径我们把画笔样式设为STROKE

这就是我们通过计算用代码画出的效果

这和我们的预期是一样的,计算了大半天总算没有白算,赶紧去抽根烟释放一下刚才计算时候紧张的心情(生怕算错),回来稳定一下情绪继续往下走。

3、处理onTouchEvent事件

3.1、处理ACTION_DOWN事件

手指按下的时候我们要判断手指所在位置是不是在起点圆上,只有按到起点圆上之后拖拽才有效,还记得我们文章开始的时候定义的变量mIsCanDrag吧

            case MotionEvent.ACTION_DOWN:
                setIsCanDrag(event);
                break;

    /**
     * 判断是否可以拖拽
     *
     * @param event event
     */
    private void setIsCanDrag(MotionEvent event) {
        Rect rect = new Rect();
        rect.left = (int) (startX - radiusStart);
        rect.top = (int) (startY - radiusStart);
        rect.right = (int) (startX + radiusStart);
        rect.bottom = (int) (startY + radiusStart);

        //触摸点是否在圆的坐标域内
        mIsCanDrag = rect.contains((int) event.getX(), (int) event.getY());
    }
3.2、处理ACTION_MOVE事件

手指按在起点圆是可move的前提,然后根据手指滑动取出移动点位置的坐标,这就是可拖拽的终点圆的坐标,

                if (mIsCanDrag) {
                    currentX = event.getX();
                    currentY = event.getY();
                    //设置拖拽圆的坐标
                    pointEnd.set(currentX, currentY);
                }

然后知道了起点圆的坐标和终点圆的坐标就可以得出所需要的各个点的坐标了,其中两圆圆心距也可以计算出来,然后根据圆心距与可拖拽最大距离的比例系数去设置两个圆的半径,当拖拽距离超过了最大距离我们通过改变状态去控制只绘制拖拽圆,否则绘制出两圆和中间的连接带,下面代码注释的很清楚了

    /**
     * 设置当前计算的到的半径
     */
    private void setCurrentRadius() {
        //两个圆心之间的距离
        float distance = (float) Math.sqrt(Math.pow(pointStart.x - pointEnd.x, 2) + Math.pow(pointStart.y - pointEnd.y, 2));

        //拖拽距离在设置的最大值范围内才绘制贝塞尔图形
        if (distance <= maxDistance) {
            //比例系数  控制两圆半径缩放
            float percent = distance / maxDistance;
            
            //之所以*0.6和0.2只为了设置拖拽过程圆变化的过大和过小这个系数是多次尝试的出的
            //你也可以适当调整系数达到自己想要的效果
            currentRadiusStart = (1 - percent * 0.6f) * radiusStart;
            currentRadiusEnd = (1 + percent * 0.2f) * radiusEnd;

            isOutOfRang = false;
        } else {
            isOutOfRang = true;
            currentRadiusStart = radiusStart;
            currentRadiusEnd = radiusEnd;
        }
    }

看下写到这一步的时候的效果

move事件处理的效果

我们发现手指松开的时候圆并没有消失或者重置,因为我们还没出来up事件。

3.3、处理ACTION_UP事件

手指抬起的时候我们要判断抬起的时候终点圆所在位置和起点圆的圆心距是否超过设置最大距离,如果没有超过就还原拖拽状态,只保留一个起点圆,如果超过了最大距离就让圆消失

                if (mIsCanDrag) {
                    if (isOutOfRang) {
                        //消失动画
                        disappear = true;
                        invalidate();
                    } else {
                        disappear = false;
                        //归位,重置各个点的坐标为开始状态
                        pointEnd.set(pointStart.x,pointStart.y);
                        setCurrentRadius();
                        setABCDOPoint();
                        invalidate();
                    }
                }

我们再来看下效果

简单的释放归位效果

看到这里核心的代码基本已经完成了,但是总感觉哪里不是很完美,哦,动画,少了一些动画效果看上去好生硬,我们就在手指离开的时候出来归位的动画

4、动画效果,锦上添花

在拖拽范围内归位的时候我们设置动画让终点圆坐标从当前位置逐渐变化到起点位置,设置BounceInterpolator让动画出现跳动效果。并且在超过可拖拽范围并且释放消失的时候加上回调方法,我们可以在消失的时候出来自己的业务逻辑

            case MotionEvent.ACTION_UP:
                if (mIsCanDrag) {
                    if (isOutOfRang) {
                        //消失动画
                        disappear = true;
                        if (onDragBallListener != null) {
                            onDragBallListener.onDisappear();
                        }
                        invalidate();
                    } else {
                        disappear = false;
                        //回弹动画
                        final float a = (pointEnd.y - pointStart.y) / (pointEnd.x - pointStart.x);
                        ValueAnimator valueAnimator = ValueAnimator.ofFloat(pointEnd.x, pointStart.x);
                        valueAnimator.setDuration(500);
                        valueAnimator.setInterpolator(new BounceInterpolator());
                        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                            @Override
                            public void onAnimationUpdate(ValueAnimator animation) {
                                float x = (float) animation.getAnimatedValue();

                                float y = pointStart.y + a * (x - pointStart.x);

                                pointEnd.set(x, y);
                                setCurrentRadius();

                                setABCDOPoint();

                                invalidate();

                            }
                        });
                        valueAnimator.start();
                    }
                }
                break;

跑下代码在看一下效果

稍微有点意思了

这样看着也不是很爽,就把画笔模式调成FILL_AND_STROKE再来看下

模拟器显示效果不是很好,真机效果很好看哦

我们可以继续完善一下,在圆中间添加数字实现消息效果

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        pointStart.set(startX, startY);
        if (isOutOfRang) {
            if (!disappear) {
                drawEndBall(canvas, pointEnd, currentRadiusEnd);
            }
        } else {
            drawStartBall(canvas, pointStart, currentRadiusStart);
            if (mIsCanDrag) {
                drawEndBall(canvas, pointEnd, currentRadiusEnd);
                drawBezier(canvas);
            }

        }

        if (!disappear) {
            if (msgCount > 0) {
                drawText(canvas, msgCount, pointEnd);
            }
        }
    }
带数字消息的效果

追求完美的人看到这里肯定会说消失的时候少个动画,对,QQ上消失的时候有个气泡破裂的感觉,这个用几张不同状态的图,加上帧动画顺序播放就可以实现,由于我这没有图片资源就不演示这个了,帧动画的写法比属性动画简单多了哦。

番外篇

我这篇文章只是起到抛砖引玉的作用,只是带领大家一步一步实现了拖拽效果,具体要怎么在项目中使用呐,大家可以根据自己的需求编写即可,网上也有几种实现方式我在此简单列出来

1、固定自定义view大小为圆的大小,显示在需要的位置,当用户触摸到view的时候把view从当前布局中移除,使用windowManage去addView(view)把我们的可拖拽View添加到window层,铺满屏幕,注意初始位置定位即可实现
2、在显示消息数的地方放置一个圆形的textView,当做初始圆,按下的时候让其隐藏,把我们的view添加到Window层做相应的拖拽

我总结了一下大概有这两种方法可行,当然你有更好的方法和思路欢迎大家在下边评论,说出你的实现方式,让大家受益,分享是一种美德,我会在评论区选出相对不错的方案加到代码中,让更多人get到更多新技能。

分享是一种美德,分享你的方案,让大家学习,共同进步。


github源码地址传送门


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,724评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,104评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,142评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,086评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,076评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,914评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,220评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,871评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,318评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,834评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,951评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,574评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,162评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,162评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,383评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,349评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,652评论 2 343

推荐阅读更多精彩内容