高仿qq‘一键下班’—让你的view‘黏’起来

qq手机客户端自5.0起有一个‘一键下班’的功能,qq聊天的消息数view可以拖拽,有一种黏黏的视觉效果,让手机控件更加生动,也增加了交互时的趣味性。最近在学习自定义控件的知识,所以试着实现了一下这个功能,来看看整体的一个预览效果:

然后看一下view的拖动特写:

主要要实现的功能:

显示消息的view被手指按住的时候随着手指移动而移动,如果触点和原位置的距离在某个距离A内,移动的view和原位置之间仍然有些‘黏黏的东西’黏住,如果距离大于A,view随手指移动而没有中间的连接部分。然后是手指抬起的情况,如果手指抬起的点到原位置距离小于距离B(B小于A),view会立即回到初始位置,这个时候如果连接还没有断开,view也会回到初始位置但是会有一个回弹的效果,如果手指抬起点到原位置距离大于B,view就不会回到原位置,且在手指抬起的位置有一个爆炸的效果。如果移除了总消息数的view,消息列表的所有view都会在原位置一一爆炸消失。

下边分析一下实现逻辑:

工具分析下qq地布局,这个显示消息数的view是Textview

,但是拖动的时候是这个样子的:

发现这个textview消失了,可见拖动的时候的绘制不是发生在这个textview上的,当然拖动发生得时候view的形状改变也不好处理。

又看了下整个布局的父控件

我靠,居然是复写的view,而且还是个容器view,直觉里边有海量的代码,虽然ViewGroup可以聊作参考,但是这又得花费哥们儿大量时间,而且有点偏离目标,只好另辟蹊径。

那么重点回到了刚才拖动的时候textview的消失,既然这玩意消失了,那么拖动的是什么鬼?直觉是海量代码容器view里边对这个拖动事件做了处理,但是我暂时还不太好复写这样一个view,于是我想到了一个替代品,使用一个占满整个布局的一个子view来代替。按住的时候隐藏textview,同时让这个替代的view显示,并接着处理以后的事件。但是有个问题当手指移出这个textview按在其他控件上的时候又会被别的控件把手指的事件拦截掉了。所以这个事件的处理应该是在最最开始就被处理掉,这个由涉及到了Android的事件分发机制,这个参考一个很直观的介绍博客事件分发,所以对事件的处理就放在了activity的dispatchTouchEvent方法中。

那么问题来了怎么判定控件触点是不是落在view内,这用到了View类的一个方法getLocationInWindow,传入一个长度为2的数组,调用之后会得到view的位置的横纵坐标,控件的宽高又可以get得到,所以就可以判断触点是不是落在了这个view内部,决定要不要做接下来的处理。

下边讲一下关键的实现细节

绘制逻辑参考了这篇文章QQ手机版 5.0“一键下班”设计小结,这个主要是一些高中几何的知识,绘制的API可以参考一下aige的自定义控件专栏(强烈推荐),也可以看一下稍后给出的demo。

SnotView的一些主要属性

    private final long KICK_BACK_DURATION = 200;// 鼻涕回弹的时长 单位ms
    private final int BOOM_DURATION = 300;// 爆炸效果时长
    public float oriX, oriY;// “钉住”的鼻涕部分的中心点
    private int oriR;// “钉住”的鼻涕部分的中心点

    public int MAX_DISTANCE;// 最大距离 超过这个距离鼻涕被扯断

    private float fingerX, fingerY;// 手指按住的点 坐标
    private int fingerR;// 拖出来的园的半径
    private int snotColor;// 鼻涕的颜色
    private Paint snotPaint;// 鼻涕画笔

    private Paint textPaint;// 文字画笔
    private int textColor;// 文字颜色
    private String text;// 文字内容

    private double newR;// 鼻涕被拖动时候 钉住部分的半径 变化的
    private double dist;// 手指和钉住点之间的距离

    // newR变化区间
    private int oriRMax;// “钉住”的鼻涕部分的最大半径
    private int oriRMin;// “钉住”的鼻涕部分做小半径
    private float textSize;// 文字的大小

    // 手指松开的一刻记录的坐标
    private float recordX;
    private float recordY;//

    private double SAFE_DISTANCE;// 安全距离
    volatile boolean hasCut;// 鼻涕是不是被扯断
    private float width, height;// 鼻涕的宽高
    private int[] imgs = new int[] { R.drawable.idp, R.drawable.idq, R.drawable.idr, R.drawable.ids, R.drawable.idt };// 动画资源
    private boolean boombing;// 是不是正在播放爆炸动画
    private Bitmap bitmap;// 动画帧资源
    Handler handler = new Handler();
    private DragCallback callback;

而我们绘制的内容是要和触摸到的view相关联的,于是有了以下这个初始化方法。参数exHeigh是ActionBar加上状态栏的高度,方便我们计算精确的坐标。注意里边有些属性值的确定相较于qq并不是确切的。

private void copyPropertiesOf(TextView view, int exHeigh) {
        int[] location = new int[2];
        view.getLocationInWindow(location);
        textSize = view.getTextSize();
        ColorDrawable cDrawable = (ColorDrawable) view.getBackground();
        snotColor = cDrawable.getColor();
        ColorStateList clist = view.getTextColors();
        textColor = clist.getDefaultColor();
        width = view.getWidth();
        height = view.getHeight();
        oriR = view.getHeight() / 2;
        oriX = location[0] + view.getWidth() / 2;
        oriY = location[1] + oriR - exHeigh;
        text = view.getText().toString();
        fingerR = oriR * 5 / 7;
        oriRMax = oriR;
        oriRMin = oriR * 2 / 5;
        MAX_DISTANCE = oriR * 6;
        SAFE_DISTANCE = oriR * 5;
        boombing = false;
        hasCut = false;
        setBackgroundColor(Color.parseColor("#00000000"));
    }

最后一句话setBackgroundColor(Color.parseColor(“#00000000”));使背景透明,因为我们的SnotView是在所有控件之上的,拖动的时候底下的部分也需要被看到。

看一看绘制方法,主要还是参考之前提到的那篇文章,有难度的可能是一些简单的几何运算,因为手机屏幕上的坐标系和几何坐标系不同,略微烧脑,不过相信对大多数人来说,把这个缕清应该不是问题。

protected void onDraw(Canvas canvas) {
        if (boombing) {
            if (bitmap != null)
                canvas.drawBitmap(bitmap, fingerX - oriR, fingerY - oriR, snotPaint);
        } else {
            drawNowOriCircleAndSnot(canvas);
            drawMovingObject(canvas);
        }
    }

    /**
     * @Description 画跟随手指移动的部分
     */
    private void drawMovingObject(Canvas canvas) {
        RectF rect1 = new RectF(fingerX - width / 2, fingerY - height / 2, fingerX + width / 2, fingerY + height / 2);
        canvas.drawRoundRect(rect1, oriR, oriR, snotPaint);
        float dX = (textPaint.measureText(text) / 2);
        float dY = -((textPaint.descent() + textPaint.ascent()) / 2);
        canvas.drawText(text, fingerX - dX, fingerY + dY, textPaint);
    }

    /**
     * 画出移动状态的原始的圆形 和中间的鼻涕
     *
     * @param canvas
     */
    private void drawNowOriCircleAndSnot(Canvas canvas) {
        dist = getDistance(fingerX, fingerY, oriX, oriY);
        if (dist <= MAX_DISTANCE && !hasCut) {
            double factor = dist / MAX_DISTANCE;
            newR = oriRMax - (oriRMax - oriRMin) * factor;
            canvas.drawCircle(oriX, oriY, (float) newR, snotPaint);
            drawSide(canvas);
        }
    }

    /**
     * 绘制两边略带弧度的线 即手指按点和原位置之间‘粘稠’的部分
     *
     * @param canvas
     */
    private void drawSide(Canvas canvas) {
        double cos = getCons(fingerX, fingerY, oriX, oriY);
        double sin = Math.sqrt(1 - cos * cos);
        double dX1 = newR * cos;
        double dY1 = newR * sin;
        double dX2 = fingerR * cos;
        double dY2 = fingerR * sin;
        Point[] p = new Point[2];
        Point[] p2 = new Point[2];

        Point[] c = new Point[2];
        c[0] = new Point((fingerX + oriX) / 2, (fingerY + oriY) / 2);
        c[1] = c[0];
        if ((fingerY >= oriY && fingerX <= oriX) || (fingerY <= oriY && fingerX >= oriX)) {
            p[0] = new Point(oriX + dX1, oriY + dY1);
            p[1] = new Point(oriX - dX1, oriY - dY1);

            p2[0] = new Point(fingerX + dX2, fingerY + dY2);
            p2[1] = new Point(fingerX - dX2, fingerY - dY2);

        } else if (fingerY >= oriY && fingerX >= oriX || (fingerY <= oriY && fingerX <= oriX)) {

            p[0] = new Point(oriX - dX1, oriY + dY1);
            p[1] = new Point(oriX + dX1, oriY - dY1);

            p2[0] = new Point(fingerX - dX2, fingerY + dY2);
            p2[1] = new Point(fingerX + dX2, fingerY - dY2);
        }
        drawStickyShape(canvas, p, p2, c);
    }

    /**
     * 贝塞尔曲线围起来的梯形
     */
    public void drawStickyShape(Canvas canvas, Point[] p, Point[] p2, Point[] c) {
        Path path = new Path();
        path.moveTo((float) p[0].x, (float) p[0].y);
        path.quadTo((float) c[0].x, (float) c[0].y, (float) p2[0].x, (float) p2[0].y);
        path.lineTo((float) p2[1].x, (float) p2[1].y);
        path.quadTo((float) c[1].x, (float) c[1].y, (float) p[1].x, (float) p[1].y);
        path.lineTo((float) p[0].x, (float) p[0].y);
        canvas.drawPath(path, snotPaint);
    }

事件的处理因为事件的来源不是SnotView本身,自然就不能在onTouchEvent方法里写。以下这个方法处理来自activity的dispatchTouchEvent事件

public synchronized void handlerTvTouchEvent2(MotionEvent event, View v, int exHeight) {
        float x = event.getX();
        float y = event.getY();

        this.fingerX = x;
        this.fingerY = y - exHeight;
        this.v = v;
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            setProperty((TextView) v, exHeight);
            setVisibility(View.VISIBLE);
            break;
        case MotionEvent.ACTION_UP:
            handleFingerUp();
            break;
        case MotionEvent.ACTION_MOVE:
            doWhenFingerMove();
            break;
        }
    }

下边讲讲手指抬起的时候回弹动画的处理,看qq的效果我首先想到的是使用属性动画,有一个overShoot的效果,但是那个回弹的次数比qq我们要的效果少一次,于是我们自定义一个插值器来实现这种效果。

/**
     *
     * @Description 回弹
     */
    private void kickback() {
        recordX = fingerX;
        recordY = fingerY;
        ValueAnimator backAnimator = ValueAnimator.ofFloat((float) dist, 0);
        OvershootInterpolator inter = new MyQQDragInterprator();
        // changeTension(inter, 4);
        backAnimator.setInterpolator(inter);
        backAnimator.setDuration(KICK_BACK_DURATION);
        backAnimator.addUpdateListener(new AnimatorUpdateListener() {

            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                doWhenKickback(animation);
            }
        });
        backAnimator.addListener(new AnimatorListener() {

            @Override
            public void onAnimationStart(Animator animation) {
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                setVisibility(View.GONE);
                if (callback != null)
                    callback.onFree();
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }
        });
        backAnimator.start();
    }

    protected void doWhenKickback(ValueAnimator animation) {
        float value = Float.parseFloat(animation.getAnimatedValue().toString());//
        final double cos = getCons(fingerX, fingerY, oriX, oriY);
        final double sin = Math.sqrt(1 - cos * cos);
        if (recordX >= oriX && recordY >= oriY) {
            fingerX = (float) (oriX + value * sin);
            fingerY = (float) (oriY + value * cos);
        } else if (recordX < oriX && recordY > oriY) {
            fingerX = (float) (oriX - value * sin);
            fingerY = (float) (oriY + value * cos);
        } else if (recordX > oriX && recordY < oriY) {
            fingerX = (float) (oriX + value * sin);
            fingerY = (float) (oriY - value * cos);
        } else {
            fingerX = (float) (oriX - value * sin);
            fingerY = (float) (oriY - value * cos);
        }
        postInvalidate();
    }

    /**
     * @Description: 回弹的时候的差值器
     * @author monkey-d-wood
     */
    private class MyQQDragInterprator extends OvershootInterpolator {
        @Override
        public float getInterpolation(float t) {
            t -= 1.0f;
            float answer1 = (float) Math.sin(Math.PI * 5 / 2 * t) * t;
            return 1 - answer1;
        }
    }

下边讲讲activity中需要做的处理,在布局文件中加入我们的自定义view并让其占满整个布局,并让其隐藏

    <com.sovnem.qqbardrag.SnotView
        android:id="@+id/snotview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#efefef"
        android:visibility="gone" />

在dispatchTouchEvent方法中处理手指事件。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        getHeights();
        tView.getLocationInWindow(location);
        float x = ev.getX();
        float y = ev.getY();
        if (ev.getAction() == MotionEvent.ACTION_DOWN && x > location[0] && x < location[0] + tView.getWidth() && y > location[1] && y < location[1] + tView.getHeight()) {
            isIn = true;
        }
        if (ev.getAction() == MotionEvent.ACTION_UP) {
            isIn = false;
            touchView.handlerTvTouchEvent2(ev, tView, exHeight);
        }
        if (isIn) {
            touchView.handlerTvTouchEvent2(ev, tView, exHeight);
            return true;
        }

        return super.dispatchTouchEvent(ev);
    }

如果是在布局中使用了Listview,在dispatchTouchEvent中需要一一判断手指触摸的到底是哪个view。如何拿到这些view?在adapter的getView方法中,将这些Textview加一个标记(Tag),监听listview的OnScroll事件,获取当前页面所有可见的item索引,通过索引和tag的一一对应关系,通过索引找到Textview,在做手指触点的判断处理。

@Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        firstVisiable = firstVisibleItem;
        visiableCout = visibleItemCount;
    }

总结

其实后来发现跟qq的拖动还是有一些差别的,也可能会有些bug,所以这个东西离实际使用还有一些差距。这篇博客目的是给出一个实现的思路,文中有纰漏和错误还望指正,上边没讲到的细节可以在随后的demo中查看。

如果这篇博客激发了你的某些灵感或解决了你的某些困惑,那我深感荣幸。

demo地址http://download.csdn.net/detail/u012293381/8933109

版权声明:本文为博主原创文章,未经博主允许不得转载。

时间: 2024-10-14 07:43:52

高仿qq‘一键下班’—让你的view‘黏’起来的相关文章

高仿QQ即时聊天软件开发系列之三登录窗口用户选择下拉框

上一篇高仿QQ即时聊天软件开发系列之二登录窗口界面写了一个大概的布局和原理 这一篇详细说下拉框的实现原理 先上最终效果图 一开始其实只是想给下拉框加一个placeholder效果,让下拉框在未选择未输入时显示一个提示字符串.由于Background对ComboBox无效,所以直接通过Background来实现是不行了.需要重新写ComboBox的模板,也就是Template,自定义一个模板来实现这个结果.又看了一下QQ的下拉框,这玩意不自定义也难以实现,所以就干脆自定义了. 先上代码,先是Com

高仿QQ即时聊天软件开发系列之二登录窗口界面

继上一篇高仿QQ即时聊天软件开发系列之一开端之后,开始做登录窗口 废话不多说,先看效果,只有界面 可能还有一些细节地方没有做,例如那个LOGO嘛,不要在意这些细节 GIF虽短,可是这做起来真难,好吧因为我没玩过WPF所以难,因为感觉做出来之后也就那样 整体布局 整体是上下分,下面是左中右分 1 <Grid> 2 <Grid.RowDefinitions> 3 <RowDefinition Height="27"><!--用于放窗口右上角关闭.最

Android高仿QQ消息滑动删除(附源码)

大家都应该使用过QQ吧,他的消息中可以滑动删除功能,我觉得比较有意思,所以模仿写了一个,并且修改了其滑动算法.我先贴几个简单示范图吧 其实主要用的是算法以及对ListView的把控. 一下是适配器的类 ? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52

高仿QQ即时聊天软件开发系列之一开端

前段时间在园子里看到一个大神做了一个GG2014IM软件,仿QQ的,那感觉···,赶快下载源码过来试试,还真能直接跑起来,效果也不错.但一看源码,全都给封装到了ESFramework里面了,音视频那部分的源码也给封装到OMCS里面了,这两家伙都是收费的Orz,还能不能好好的看看源码L 大概看了一下GG(为什么每次念这个词都有种怪怪的感觉)的源码,突然想自己也来弄个高仿QQ的软件,平时时间也比较多,就当锻炼吧.别人GG都有个名字,那我也起个名字吧,就叫CC吧 决定了就开始安排 第一个问题,界面框架

Android插件化的思考——仿QQ一键换肤,思考比实现更重要!

Android插件化的思考--仿QQ一键换肤,思考比实现更重要! 今天群友希望写一个关于插件的Blog,思来想去,插件也不是很懂,只是用大致的思路看看能不能模拟一个,思路还是比较重要的,如果你有兴趣的话,也可以加群:555974449,你也可以说出你想看的Blog哦,嘿嘿!好的,不多说,我们进入正题: 关于QQ的换肤,他们的实现思路我不是很清楚,但是你可以看一下这张换肤的截图 我们想使用哪个主题就直接下载就好了,这一实现的过程我们大致的可以猜想: 首选是下载到本地指定文件夹,然后通过插件加载到我

【Android】史上最简单,一步集成侧滑(删除)菜单,高仿QQ、IOS

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布 转载请标明出处: http://blog.csdn.net/zxt0601/article/details/53157090 本文出自:[张旭童的博客](http://blog.csdn.net/zxt0601) 代码传送门:喜欢的话,随手点个star.多谢 https://github.com/mcxtzhang/SwipeDelMenuLayout 重要的话 开头说,not for the RecyclerView or L

高仿QQ头像截取升级版

观看此篇文章前,请先阅读上篇文章:高仿QQ头像截取: 本篇之所以为升级版,是在截取头像界面添加了与qq类似的阴影层(裁剪区域以外的部分),且看效果图:   为了适应大家不同需求,这次打了两个包,及上图中一个方形的头像截取demo和一个圆形的: 原理: 方形: 如图:底层即图片层,在上层的画布中,先将裁剪区四周根据裁剪区大小画上阴影,然后在画上裁剪区的白色边框(空心):如下图 主要代码如下: @Override protected void onDraw(Canvas canvas) { supe

史上最简单,一步集成侧滑(删除)菜单,高仿QQ、IOS。

重要的话 开头说,not for the RecyclerView or ListView, for the Any ViewGroup. 本控件不依赖任何父布局,不是针对 RecyclerView.ListView,而是任意的ViewGroup里的childView都可以使用侧滑(删除)菜单.支持任意ViewGroup.0耦合.史上最简单. 概述 本控件从撸出来在项目使用至今已经过去7个月,距离第一次将它push至github上,也已经2月+.(之前,我发表过一篇文章.传送门:http://b

高仿QQ的个性名片

效果图 中间的圆形头像和光环波形讲解请看:http://blog.csdn.net/cj_286/article/details/52839036 周围的气泡布局,因为布局RatioLayout是继承自ViewGroup,所以布局layout就可以根据自己的需求来布局其子view,view.layout(int l,int t,int r,int b);用于布局子view在父ViewGroup中的位置(相对于父容器),所以在RatioLayout中计算所有子view的left,top,right