Android自定义控件:类QQ未读消息拖拽效果

QQ的未读消息,算是一个比较好玩的效果,趁着最近时间比较多,参考了网上的一些资料之后,本次实现一个仿照QQ未读消息的拖拽小红点:

首先我们从最基本的原理开始分析,看一张图:

Android自定义控件:类QQ未读消息拖拽效果

这个图该怎么绘制呢?实际上我们这里是先绘制两个圆,然后将两个圆的切点通过贝塞尔曲线连接起来就达到这个效果了。至于贝塞尔曲线的概念,这里就不多做解释了,百度一下就知道了。

Android自定义控件:类QQ未读消息拖拽效果

切点怎么算呢,这里我们稍微复习一些初中的数学知识。看了这个图之后,求出四个切点应该是轻而易举了。

Android自定义控件:类QQ未读消息拖拽效果

现在思路已经很清晰了,按照我们的思路,开撸。

首先是我们计算切点以及各坐标点的工具类


  1. public class GeometryUtils { 
  2.     /** 
  3.      * As meaning of method name
  4.      * 获得两点之间的距离 
  5.      * @param p0 
  6.      * @param p1 
  7.      * @return 
  8.      */ 
  9.     public static float getDistanceBetween2Points(PointF p0, PointF p1) { 
  10.         float distance = (float) Math.sqrt(Math.pow(p0.y - p1.y, 2) + Math.pow(p0.x - p1.x, 2)); 
  11.         return distance; 
  12.     } 
  13.  
  14.     /** 
  15.      * Get middle point between p1 and p2. 
  16.      * 获得两点连线的中点 
  17.      * @param p1 
  18.      * @param p2 
  19.      * @return 
  20.      */ 
  21.     public static PointF getMiddlePoint(PointF p1, PointF p2) { 
  22.         return new PointF((p1.x + p2.x) / 2.0f, (p1.y + p2.y) / 2.0f); 
  23.     } 
  24.  
  25.     /** 
  26.      * Get point between p1 and p2 by percent. 
  27.      * 根据百分比获取两点之间的某个点坐标 
  28.      * @param p1 
  29.      * @param p2 
  30.      * @param percent 
  31.      * @return 
  32.      */ 
  33.     public static PointF getPointByPercent(PointF p1, PointF p2, float percent) { 
  34.         return new PointF(evaluateValue(percent, p1.x , p2.x), evaluateValue(percent, p1.y , p2.y)); 
  35.     } 
  36.  
  37.     /** 
  38.      * 根据分度值,计算从start到end中,fraction位置的值。fraction范围为0 -> 1 
  39.      * @param fraction 
  40.      * @param start 
  41.      * @param end 
  42.      * @return 
  43.      */ 
  44.     public static float evaluateValue(float fraction, Number start, Number end){ 
  45.         return start.floatValue() + (end.floatValue() - start.floatValue()) * fraction; 
  46.     } 
  47.  
  48.     /** 
  49.      * Get the point of intersection between circle and line. 
  50.      * 获取 通过指定圆心,斜率为lineK的直线与圆的交点。 
  51.      * 
  52.      * @param pMiddle The circle center point. 
  53.      * @param radius The circle radius. 
  54.      * @param lineK The slope of line which cross the pMiddle. 
  55.      * @return 
  56.      */ 
  57.     public static PointF[] getIntersectionPoints(PointF pMiddle, float radius, Double lineK) { 
  58.         PointF[] points = new PointF[2]; 
  59.  
  60.         float radian, xOffset = 0, yOffset = 0; 
  61.         if(lineK != null){ 
  62.             radian= (float) Math.atan(lineK); 
  63.             xOffset = (float) (Math.sin(radian) * radius); 
  64.             yOffset = (float) (Math.cos(radian) * radius); 
  65.         }else { 
  66.             xOffset = radius; 
  67.             yOffset = 0; 
  68.         } 
  69.         points[0] = new PointF(pMiddle.x + xOffset, pMiddle.y - yOffset); 
  70.         points[1] = new PointF(pMiddle.x - xOffset, pMiddle.y + yOffset); 
  71.  
  72.         return points; 
  73.     } 
  74.  

然后下面看下我们的核心绘制代码,代码注释比较全,此处就不多做解释了。


  1. /** 
  2.      * 绘制贝塞尔曲线部分以及固定圆 
  3.      * 
  4.      * @param canvas 
  5.      */ 
  6.     private void drawGooPath(Canvas canvas) { 
  7.         Path path = new Path(); 
  8.         //1. 根据当前两圆圆心的距离计算出固定圆的半径 
  9.         float distance = (float) GeometryUtils.getDistanceBetween2Points(mDragCenter, mStickCenter); 
  10.         stickCircleTempRadius = getCurrentRadius(distance); 
  11.  
  12.         //2. 计算出经过两圆圆心连线的垂线的dragLineK(对边比临边)。求出四个交点坐标 
  13.         float xDiff = mStickCenter.x - mDragCenter.x; 
  14.         Double dragLineK = null
  15.         if (xDiff != 0) { 
  16.             dragLineK = (double) ((mStickCenter.y - mDragCenter.y) / xDiff); 
  17.         } 
  18.  
  19.         //分别获得经过两圆圆心连线的垂线与圆的交点(两条垂线平行,所以dragLineK相等)。 
  20.         PointF[] dragPoints = GeometryUtils.getIntersectionPoints(mDragCenter, dragCircleRadius, dragLineK); 
  21.         PointF[] stickPoints = GeometryUtils.getIntersectionPoints(mStickCenter, stickCircleTempRadius, dragLineK); 
  22.  
  23.         //3. 以两圆连线的0.618处作为 贝塞尔曲线 的控制点。(选一个中间点附近的控制点) 
  24.         PointF pointByPercent = GeometryUtils.getPointByPercent(mDragCenter, mStickCenter, 0.618f); 
  25.  
  26.         // 绘制两圆连接闭合 
  27.         path.moveTo((float) stickPoints[0].x, (float) stickPoints[0].y); 
  28.         path.quadTo((float) pointByPercent.x, (float) pointByPercent.y, 
  29.                 (float) dragPoints[0].x, (float) dragPoints[0].y); 
  30.         path.lineTo((float) dragPoints[1].x, (float) dragPoints[1].y); 
  31.         path.quadTo((float) pointByPercent.x, (float) pointByPercent.y, 
  32.                 (float) stickPoints[1].x, (float) stickPoints[1].y); 
  33.         canvas.drawPath(path, mPaintRed); 
  34.         // 画固定圆 
  35.         canvas.drawCircle(mStickCenter.x, mStickCenter.y, stickCircleTempRadius, mPaintRed); 
  36.     }  

此时我们已经实现了绘制的核心代码,然后我们加上touch事件的监听,达到动态的更新dragPoint的中心点位置以及stickPoint半径的效果。当手抬起的时候,添加一个属性动画,达到回弹的效果。


  1. @Override 
  2.     public boolean onTouchEvent(MotionEvent event) { 
  3.         switch (MotionEventCompat.getActionMasked(event)) { 
  4.             case MotionEvent.ACTION_DOWN: { 
  5.                 isOutOfRange = false
  6.                 updateDragPointCenter(event.getRawX(), event.getRawY()); 
  7.                 break; 
  8.             } 
  9.             case MotionEvent.ACTION_MOVE: { 
  10.                 //如果两圆间距大于最大距离mMaxDistance,执行拖拽结束动画 
  11.                 PointF p0 = new PointF(mDragCenter.x, mDragCenter.y); 
  12.                 PointF p1 = new PointF(mStickCenter.x, mStickCenter.y); 
  13.                 if (GeometryUtils.getDistanceBetween2Points(p0, p1) > mMaxDistance) { 
  14.                     isOutOfRange = true
  15.                     updateDragPointCenter(event.getRawX(), event.getRawY()); 
  16.                     return false
  17.                 } 
  18.                 updateDragPointCenter(event.getRawX(), event.getRawY()); 
  19.                 break; 
  20.             } 
  21.             case MotionEvent.ACTION_UP: { 
  22.                 handleActionUp(); 
  23.                 break; 
  24.             } 
  25.             default: { 
  26.                 isOutOfRange = false
  27.                 break; 
  28.             } 
  29.         } 
  30.         return true
  31.     } 
  32.  
  33.     /** 
  34.      * 手势抬起动作 
  35.      */ 
  36.     private void handleActionUp() { 
  37.         if (isOutOfRange) { 
  38.             // 当拖动dragPoint范围已经超出mMaxDistance,然后又将dragPoint拖回mResetDistance范围内时 
  39.             if (GeometryUtils.getDistanceBetween2Points(mDragCenter, mStickCenter) < mResetDistance) { 
  40.                 //reset 
  41.                 return
  42.             } 
  43.             // dispappear 
  44.         } else { 
  45.             //手指抬起时,弹回动画 
  46.             mAnim = ValueAnimator.ofFloat(1.0f); 
  47.             mAnim.setInterpolator(new OvershootInterpolator(5.0f)); 
  48.  
  49.             final PointF startPoint = new PointF(mDragCenter.x, mDragCenter.y); 
  50.             final PointF endPoint = new PointF(mStickCenter.x, mStickCenter.y); 
  51.             mAnim.addUpdateListener(new AnimatorUpdateListener() { 
  52.                 @Override 
  53.                 public void onAnimationUpdate(ValueAnimator animation) { 
  54.                     float fraction = animation.getAnimatedFraction(); 
  55.                     PointF pointByPercent = GeometryUtils.getPointByPercent(startPoint, endPoint, fraction); 
  56.                     updateDragPointCenter((float) pointByPercent.x, (float) pointByPercent.y); 
  57.                 } 
  58.             }); 
  59.             mAnim.addListener(new AnimatorListenerAdapter() { 
  60.                 @Override 
  61.                 public void onAnimationEnd(Animator animation) { 
  62.                     //reset 
  63.                 } 
  64.             }); 
  65.  
  66.             if (GeometryUtils.getDistanceBetween2Points(startPoint, endPoint) < 10) { 
  67.                 mAnim.setDuration(100); 
  68.             } else { 
  69.                 mAnim.setDuration(300); 
  70.             } 
  71.             mAnim.start(); 
  72.         } 
  73.     }  

此时我们拖拽的核心代码基本都已经完成,实际效果如下:

Android自定义控件:类QQ未读消息拖拽效果

现在小红点的绘制基本告一段落,我们不得不去思考真正的难点。那就是如何将我们前面的这个GooView应用到实际呢?看实际效果我们的小红点是放在listView里面的,如果是这样的话,就代表我们的GooView的拖拽范围是肯定无法超过父控件item的区域的。

那么我们要如何实现小红点可以随便的在整个屏幕拖拽呢?我们这里稍微整理一下思路。

1.先在listView的item布局中先放入一个小红点。

2.当我们touch到这个小红点的时候,隐藏这个小红点,然后根据我们布局中小红点的位置初始化一个GooView并且添加到WindowManager中吗,达到GooView可以全屏拖动的效果。

3.在添加GooView到WindowManager中的时候,记录初始小红点stickPoint的位置,然后根据stickPoint和dragPointde位置是否超出我们的消失界限来判断接下来的逻辑。

4.根据GooView的最终状态,显示回弹或者消失动画。

思路有了,那么就上代码,根据第一步,我们完成listView的item布局。


  1. <?xml version="1.0" encoding="utf-8"?> 
  2. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" 
  3.                 android:layout_width="match_parent" 
  4.                 android:layout_height="80dp" 
  5.                 android:minHeight="80dp"
  6.  
  7.     <ImageView 
  8.         android:id="@+id/iv_head" 
  9.         android:layout_width="50dp" 
  10.         android:layout_height="50dp" 
  11.         android:layout_centerVertical="true" 
  12.         android:layout_marginLeft="20dp" 
  13.         android:src="@mipmap/head"/> 
  14.  
  15.     <TextView 
  16.         android:id="@+id/tv_content" 
  17.         android:layout_width="wrap_content" 
  18.         android:layout_height="50dp" 
  19.         android:layout_centerVertical="true" 
  20.         android:gravity="center" 
  21.         android:layout_marginLeft="20dp" 
  22.         android:layout_toRightOf="@+id/iv_head" 
  23.         android:text="content - " 
  24.         android:textSize="25sp"/> 
  25.  
  26.     <LinearLayout 
  27.         android:id="@+id/ll_point" 
  28.         android:layout_width="80dp" 
  29.         android:layout_height="80dp" 
  30.         android:layout_alignParentEnd="true" 
  31.         android:layout_alignParentRight="true" 
  32.         android:layout_alignParentTop="true" 
  33.         android:gravity="center"
  34.  
  35.         <TextView 
  36.             android:id="@+id/point" 
  37.             android:layout_width="wrap_content" 
  38.             android:layout_height="18dp" 
  39.             android:background="@drawable/red_bg" 
  40.             android:gravity="center" 
  41.             android:singleLine="true" 
  42.             android:textColor="@android:color/white" 
  43.             android:textSize="12sp"/> 
  44.     </LinearLayout> 
  45. </RelativeLayout>  

效果如下,要注意的是,对比QQ的真实体验,小红点周边范围点击的时候,都是可以直接拖拽小红点的。考虑到红点的点击范围比较小,所以给红点增加了一个宽高80dp的父layout,然后我们将touch小红点事件更改为touch小红点父layout,这样只要我们点击了小红点的父layout范围,都会添加GooView到WindowManager中。

Android自定义控件:类QQ未读消息拖拽效果

接下来第二步,我们完成添加GooView到WindowManager中的代码。

由于我们的GooView初始添加是从listViewItem中红点的touch事件开始的,所以我们先完成listView adapter的实现。


  1. public class GooViewAapter extends BaseAdapter { 
  2.     private Context mContext; 
  3.     //记录已经remove的position 
  4.     private HashSet<Integer> mRemoved = new HashSet<Integer>(); 
  5.     private List<String> list = new ArrayList<String>(); 
  6.  
  7.     public GooViewAapter(Context mContext, List<String> list) { 
  8.         super(); 
  9.         this.mContext = mContext; 
  10.         this.list = list; 
  11.     } 
  12.  
  13.     @Override 
  14.     public int getCount() { 
  15.         return list.size(); 
  16.     } 
  17.  
  18.     @Override 
  19.     public Object getItem(int position) { 
  20.         return list.get(position); 
  21.     } 
  22.  
  23.     @Override 
  24.     public long getItemId(int position) { 
  25.         return position; 
  26.     } 
  27.  
  28.     @Override 
  29.     public View getView(final int position, View convertView, ViewGroup parent) { 
  30.         if (convertView == null) { 
  31.             convertView = View.inflate(mContext, R.layout.list_item_goo, null); 
  32.         } 
  33.         ViewHolder holder = ViewHolder.getHolder(convertView); 
  34.         holder.mContent.setText(list.get(position)); 
  35.         //item固定小红点layout 
  36.         LinearLayout pointLayout = holder.mPointLayout; 
  37.         //item固定小红点 
  38.         final TextView point = holder.mPoint; 
  39.  
  40.         boolean visiable = !mRemoved.contains(position); 
  41.         pointLayout.setVisibility(visiable ? View.VISIBLE : View.GONE); 
  42.         if (visiable) { 
  43.             point.setText(String.valueOf(position)); 
  44.             pointLayout.setTag(position); 
  45.             GooViewListener mGooListener = new GooViewListener(mContext, pointLayout) { 
  46.                 @Override 
  47.                 public void onDisappear(PointF mDragCenter) { 
  48.                     super.onDisappear(mDragCenter); 
  49.                     mRemoved.add(position); 
  50.                     notifyDataSetChanged(); 
  51.                     Utils.showToast(mContext, "position " + position + " disappear."); 
  52.                 } 
  53.  
  54.                 @Override 
  55.                 public void onReset(boolean isOutOfRange) { 
  56.                     super.onReset(isOutOfRange); 
  57.                     notifyDataSetChanged();//刷新ListView 
  58.                     Utils.showToast(mContext, "position " + position + " reset."); 
  59.                 } 
  60.             }; 
  61.             //在point父布局内的触碰事件都进行监听 
  62.             pointLayout.setOnTouchListener(mGooListener); 
  63.         } 
  64.         return convertView; 
  65.     } 
  66.  
  67.     static class ViewHolder { 
  68.  
  69.         public ImageView mImage; 
  70.         public TextView mPoint; 
  71.         public LinearLayout mPointLayout; 
  72.         public TextView mContent; 
  73.  
  74.         public ViewHolder(View convertView) { 
  75.             mImage = (ImageView) convertView.findViewById(R.id.iv_head); 
  76.             mPoint = (TextView) convertView.findViewById(R.id.point); 
  77.             mPointLayout = (LinearLayout) convertView.findViewById(R.id.ll_point); 
  78.             mContent = (TextView) convertView.findViewById(R.id.tv_content); 
  79.         } 
  80.  
  81.         public static ViewHolder getHolder(View convertView) { 
  82.             ViewHolder holder = (ViewHolder) convertView.getTag(); 
  83.             if (holder == null) { 
  84.                 holder = new ViewHolder(convertView); 
  85.                 convertView.setTag(holder); 
  86.             } 
  87.             return holder; 
  88.         } 
  89.     } 
  90.  

由于listview需要知道GooView的状态,所以我们在GooView中增加一个接口,用于listView回调处理后续的逻辑。


  1. interface OnDisappearListener { 
  2.         /** 
  3.          * GooView Disapper 
  4.          * 
  5.          * @param mDragCenter 
  6.          */ 
  7.         void onDisappear(PointF mDragCenter); 
  8.  
  9.         /** 
  10.          * GooView onReset 
  11.          * 
  12.          * @param isOutOfRange 
  13.          */ 
  14.         void onReset(boolean isOutOfRange); 
  15.       }  

新建一个实现了OnTouchListener以及OnDisappearListener 方法的的类,最后将这个实现类设置给item中的红点Layout。


  1. public class GooViewListener implements OnTouchListener, OnDisappearListener { 
  2.  
  3.     private WindowManager mWm; 
  4.     private WindowManager.LayoutParams mParams; 
  5.     private GooView mGooView; 
  6.     private View pointLayout; 
  7.     private int number; 
  8.     private final Context mContext; 
  9.  
  10.     private Handler mHandler; 
  11.  
  12.     public GooViewListener(Context mContext, View pointLayout) { 
  13.         this.mContext = mContext; 
  14.         this.pointLayout = pointLayout; 
  15.         this.number = (Integer) pointLayout.getTag(); 
  16.  
  17.         mGooView = new GooView(mContext); 
  18.  
  19.         mWm = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); 
  20.         mParams = new WindowManager.LayoutParams(); 
  21.         mParams.format = PixelFormat.TRANSLUCENT;//使窗口支持透明度 
  22.         mHandler = new Handler(mContext.getMainLooper()); 
  23.     } 
  24.  
  25.     @Override 
  26.     public boolean onTouch(View v, MotionEvent event) { 
  27.         int action = MotionEventCompat.getActionMasked(event); 
  28.         // 当按下时,将自定义View添加到WindowManager中 
  29.         if (action == MotionEvent.ACTION_DOWN) { 
  30.             ViewParent parent = v.getParent(); 
  31.             // 请求其父级View不拦截Touch事件 
  32.             parent.requestDisallowInterceptTouchEvent(true); 
  33.  
  34.             int[] points = new int[2]; 
  35.             //获取pointLayout在屏幕中的位置(layout的左上角坐标) 
  36.             pointLayout.getLocationInWindow(points); 
  37.             //获取初始小红点中心坐标 
  38.             int x = points[0] + pointLayout.getWidth() / 2; 
  39.             int y = points[1] + pointLayout.getHeight() / 2; 
  40.             // 初始化当前点击的item的信息,数字及坐标 
  41.             mGooView.setStatusBarHeight(Utils.getStatusBarHeight(v)); 
  42.             mGooView.setNumber(number); 
  43.             mGooView.initCenter(x, y); 
  44.             //设置当前GooView消失监听 
  45.             mGooView.setOnDisappearListener(this); 
  46.             // 添加当前GooView到WindowManager 
  47.             mWm.addView(mGooView, mParams); 
  48.             pointLayout.setVisibility(View.INVISIBLE); 
  49.         } 
  50.         // 将所有touch事件转交给GooView处理 
  51.         mGooView.onTouchEvent(event); 
  52.         return true
  53.     } 
  54.  
  55.     @Override 
  56.     public void onDisappear(PointF mDragCenter) { 
  57.         //disappear 下一步完成 
  58.     } 
  59.  
  60.     @Override 
  61.     public void onReset(boolean isOutOfRange) { 
  62.         // 当dragPoint弹回时,去除该View,等下次ACTION_DOWN的时候再添加 
  63.         if (mWm != null && mGooView.getParent() != null) { 
  64.             mWm.removeView(mGooView); 
  65.         } 
  66.     } 
  67.  

这样下来,我们基本上完成了大部分功能,现在还差最后一步,就是GooView超出范围消失后的处理,这里我们用一个帧动画来完成爆炸效果。


  1. public class BubbleLayout extends FrameLayout { 
  2.     Context context; 
  3.  
  4.     public BubbleLayout(Context context) { 
  5.         super(context); 
  6.         this.context = context; 
  7.     } 
  8.  
  9.     private int mCenterX, mCenterY; 
  10.  
  11.     public void setCenter(int x, int y) { 
  12.         mCenterX = x; 
  13.         mCenterY = y; 
  14.         requestLayout(); 
  15.     } 
  16.  
  17.     @Override 
  18.     protected void onLayout(boolean changed, int leftint topint right
  19.                             int bottom) { 
  20.         View child = getChildAt(0); 
  21.         // 设置View到指定位置 
  22.         if (child != null && child.getVisibility() != GONE) { 
  23.             final int width = child.getMeasuredWidth(); 
  24.             final int height = child.getMeasuredHeight(); 
  25.             child.layout((int) (mCenterX - width / 2.0f), (int) (mCenterY - height / 2.0f) 
  26.                     , (int) (mCenterX + width / 2.0f), (int) (mCenterY + height / 2.0f)); 
  27.         } 
  28.     } 
  29.  
  30. @Override 
  31.     public void onDisappear(PointF mDragCenter) { 
  32.         if (mWm != null && mGooView.getParent() != null) { 
  33.             mWm.removeView(mGooView); 
  34.  
  35.             //播放气泡爆炸动画 
  36.             ImageView imageView = new ImageView(mContext); 
  37.             imageView.setImageResource(R.drawable.anim_bubble_pop); 
  38.             AnimationDrawable mAnimDrawable = (AnimationDrawable) imageView 
  39.                     .getDrawable(); 
  40.  
  41.             final BubbleLayout bubbleLayout = new BubbleLayout(mContext); 
  42.             bubbleLayout.setCenter((int) mDragCenter.x, (int) mDragCenter.y - Utils.getStatusBarHeight(mGooView)); 
  43.  
  44.             bubbleLayout.addView(imageView, new FrameLayout.LayoutParams( 
  45.                     android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, 
  46.                     android.widget.FrameLayout.LayoutParams.WRAP_CONTENT)); 
  47.  
  48.             mWm.addView(bubbleLayout, mParams); 
  49.  
  50.             mAnimDrawable.start(); 
  51.  
  52.             // 播放结束后,删除该bubbleLayout 
  53.             mHandler.postDelayed(new Runnable() { 
  54.                 @Override 
  55.                 public void run() { 
  56.                     mWm.removeView(bubbleLayout); 
  57.                 } 
  58.             }, 501); 
  59.         } 
  60.     }  

最后附上完整demo地址:https://github.com/Horrarndoo/GooView 





本文作者:佚名
来源:51CTO
上一篇:C++析构函数不能失败的4个理由


下一篇:程序员的量化交易之路(3)--Esper事件Event(2)