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
作者:LOSTINCODEs