Android自定義控件:類QQ未讀消息拖拽效果
QQ的未讀消息,算是一個比較好玩的效果,趁著最近時間比較多,參考了網(wǎng)上的一些資料之后,本次實現(xiàn)一個仿照QQ未讀消息的拖拽小紅點:
首先我們從最基本的原理開始分析,看一張圖:
這個圖該怎么繪制呢?實際上我們這里是先繪制兩個圓,然后將兩個圓的切點通過貝塞爾曲線連接起來就達到這個效果了。至于貝塞爾曲線的概念,這里就不多做解釋了,百度一下就知道了。
切點怎么算呢,這里我們稍微復習一些初中的數(shù)學知識??戳诉@個圖之后,求出四個切點應該是輕而易舉了。
現(xiàn)在思路已經(jīng)很清晰了,按照我們的思路,開擼。
首先是我們計算切點以及各坐標點的工具類
- public class GeometryUtils {
- /**
- * As meaning of method name.
- * 獲得兩點之間的距離
- * @param p0
- * @param p1
- * @return
- */
- public static float getDistanceBetween2Points(PointF p0, PointF p1) {
- float distance = (float) Math.sqrt(Math.pow(p0.y - p1.y, 2) + Math.pow(p0.x - p1.x, 2));
- return distance;
- }
- /**
- * Get middle point between p1 and p2.
- * 獲得兩點連線的中點
- * @param p1
- * @param p2
- * @return
- */
- public static PointF getMiddlePoint(PointF p1, PointF p2) {
- return new PointF((p1.x + p2.x) / 2.0f, (p1.y + p2.y) / 2.0f);
- }
- /**
- * Get point between p1 and p2 by percent.
- * 根據(jù)百分比獲取兩點之間的某個點坐標
- * @param p1
- * @param p2
- * @param percent
- * @return
- */
- public static PointF getPointByPercent(PointF p1, PointF p2, float percent) {
- return new PointF(evaluateValue(percent, p1.x , p2.x), evaluateValue(percent, p1.y , p2.y));
- }
- /**
- * 根據(jù)分度值,計算從start到end中,fraction位置的值。fraction范圍為0 -> 1
- * @param fraction
- * @param start
- * @param end
- * @return
- */
- public static float evaluateValue(float fraction, Number start, Number end){
- return start.floatValue() + (end.floatValue() - start.floatValue()) * fraction;
- }
- /**
- * Get the point of intersection between circle and line.
- * 獲取 通過指定圓心,斜率為lineK的直線與圓的交點。
- *
- * @param pMiddle The circle center point.
- * @param radius The circle radius.
- * @param lineK The slope of line which cross the pMiddle.
- * @return
- */
- public static PointF[] getIntersectionPoints(PointF pMiddle, float radius, Double lineK) {
- PointF[] points = new PointF[2];
- float radian, xOffset = 0, yOffset = 0;
- if(lineK != null){
- radian= (float) Math.atan(lineK);
- xOffset = (float) (Math.sin(radian) * radius);
- yOffset = (float) (Math.cos(radian) * radius);
- }else {
- xOffset = radius;
- yOffset = 0;
- }
- points[0] = new PointF(pMiddle.x + xOffset, pMiddle.y - yOffset);
- points[1] = new PointF(pMiddle.x - xOffset, pMiddle.y + yOffset);
- return points;
- }
- }
然后下面看下我們的核心繪制代碼,代碼注釋比較全,此處就不多做解釋了。
- /**
- * 繪制貝塞爾曲線部分以及固定圓
- *
- * @param canvas
- */
- private void drawGooPath(Canvas canvas) {
- Path path = new Path();
- //1. 根據(jù)當前兩圓圓心的距離計算出固定圓的半徑
- float distance = (float) GeometryUtils.getDistanceBetween2Points(mDragCenter, mStickCenter);
- stickCircleTempRadius = getCurrentRadius(distance);
- //2. 計算出經(jīng)過兩圓圓心連線的垂線的dragLineK(對邊比臨邊)。求出四個交點坐標
- float xDiff = mStickCenter.x - mDragCenter.x;
- Double dragLineK = null;
- if (xDiff != 0) {
- dragLineK = (double) ((mStickCenter.y - mDragCenter.y) / xDiff);
- }
- //分別獲得經(jīng)過兩圓圓心連線的垂線與圓的交點(兩條垂線平行,所以dragLineK相等)。
- PointF[] dragPoints = GeometryUtils.getIntersectionPoints(mDragCenter, dragCircleRadius, dragLineK);
- PointF[] stickPoints = GeometryUtils.getIntersectionPoints(mStickCenter, stickCircleTempRadius, dragLineK);
- //3. 以兩圓連線的0.618處作為 貝塞爾曲線 的控制點。(選一個中間點附近的控制點)
- PointF pointByPercent = GeometryUtils.getPointByPercent(mDragCenter, mStickCenter, 0.618f);
- // 繪制兩圓連接閉合
- path.moveTo((float) stickPoints[0].x, (float) stickPoints[0].y);
- path.quadTo((float) pointByPercent.x, (float) pointByPercent.y,
- (float) dragPoints[0].x, (float) dragPoints[0].y);
- path.lineTo((float) dragPoints[1].x, (float) dragPoints[1].y);
- path.quadTo((float) pointByPercent.x, (float) pointByPercent.y,
- (float) stickPoints[1].x, (float) stickPoints[1].y);
- canvas.drawPath(path, mPaintRed);
- // 畫固定圓
- canvas.drawCircle(mStickCenter.x, mStickCenter.y, stickCircleTempRadius, mPaintRed);
- }
此時我們已經(jīng)實現(xiàn)了繪制的核心代碼,然后我們加上touch事件的監(jiān)聽,達到動態(tài)的更新dragPoint的中心點位置以及stickPoint半徑的效果。當手抬起的時候,添加一個屬性動畫,達到回彈的效果。
- @Override
- public boolean onTouchEvent(MotionEvent event) {
- switch (MotionEventCompat.getActionMasked(event)) {
- case MotionEvent.ACTION_DOWN: {
- isOutOfRange = false;
- updateDragPointCenter(event.getRawX(), event.getRawY());
- break;
- }
- case MotionEvent.ACTION_MOVE: {
- //如果兩圓間距大于***距離mMaxDistance,執(zhí)行拖拽結(jié)束動畫
- PointF p0 = new PointF(mDragCenter.x, mDragCenter.y);
- PointF p1 = new PointF(mStickCenter.x, mStickCenter.y);
- if (GeometryUtils.getDistanceBetween2Points(p0, p1) > mMaxDistance) {
- isOutOfRange = true;
- updateDragPointCenter(event.getRawX(), event.getRawY());
- return false;
- }
- updateDragPointCenter(event.getRawX(), event.getRawY());
- break;
- }
- case MotionEvent.ACTION_UP: {
- handleActionUp();
- break;
- }
- default: {
- isOutOfRange = false;
- break;
- }
- }
- return true;
- }
- /**
- * 手勢抬起動作
- */
- private void handleActionUp() {
- if (isOutOfRange) {
- // 當拖動dragPoint范圍已經(jīng)超出mMaxDistance,然后又將dragPoint拖回mResetDistance范圍內(nèi)時
- if (GeometryUtils.getDistanceBetween2Points(mDragCenter, mStickCenter) < mResetDistance) {
- //reset
- return;
- }
- // dispappear
- } else {
- //手指抬起時,彈回動畫
- mAnim = ValueAnimator.ofFloat(1.0f);
- mAnim.setInterpolator(new OvershootInterpolator(5.0f));
- final PointF startPoint = new PointF(mDragCenter.x, mDragCenter.y);
- final PointF endPoint = new PointF(mStickCenter.x, mStickCenter.y);
- mAnim.addUpdateListener(new AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(ValueAnimator animation) {
- float fraction = animation.getAnimatedFraction();
- PointF pointByPercent = GeometryUtils.getPointByPercent(startPoint, endPoint, fraction);
- updateDragPointCenter((float) pointByPercent.x, (float) pointByPercent.y);
- }
- });
- mAnim.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(Animator animation) {
- //reset
- }
- });
- if (GeometryUtils.getDistanceBetween2Points(startPoint, endPoint) < 10) {
- mAnim.setDuration(100);
- } else {
- mAnim.setDuration(300);
- }
- mAnim.start();
- }
- }
此時我們拖拽的核心代碼基本都已經(jīng)完成,實際效果如下:
現(xiàn)在小紅點的繪制基本告一段落,我們不得不去思考真正的難點。那就是如何將我們前面的這個GooView應用到實際呢?看實際效果我們的小紅點是放在listView里面的,如果是這樣的話,就代表我們的GooView的拖拽范圍是肯定無法超過父控件item的區(qū)域的。
那么我們要如何實現(xiàn)小紅點可以隨便的在整個屏幕拖拽呢?我們這里稍微整理一下思路。
1.先在listView的item布局中先放入一個小紅點。
2.當我們touch到這個小紅點的時候,隱藏這個小紅點,然后根據(jù)我們布局中小紅點的位置初始化一個GooView并且添加到WindowManager中嗎,達到GooView可以全屏拖動的效果。
3.在添加GooView到WindowManager中的時候,記錄初始小紅點stickPoint的位置,然后根據(jù)stickPoint和dragPointde位置是否超出我們的消失界限來判斷接下來的邏輯。
4.根據(jù)GooView的最終狀態(tài),顯示回彈或者消失動畫。
思路有了,那么就上代碼,根據(jù)***步,我們完成listView的item布局。
- <?xml version="1.0" encoding="utf-8"?>
- <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="80dp"
- android:minHeight="80dp">
- <ImageView
- android:id="@+id/iv_head"
- android:layout_width="50dp"
- android:layout_height="50dp"
- android:layout_centerVertical="true"
- android:layout_marginLeft="20dp"
- android:src="@mipmap/head"/>
- <TextView
- android:id="@+id/tv_content"
- android:layout_width="wrap_content"
- android:layout_height="50dp"
- android:layout_centerVertical="true"
- android:gravity="center"
- android:layout_marginLeft="20dp"
- android:layout_toRightOf="@+id/iv_head"
- android:text="content - "
- android:textSize="25sp"/>
- <LinearLayout
- android:id="@+id/ll_point"
- android:layout_width="80dp"
- android:layout_height="80dp"
- android:layout_alignParentEnd="true"
- android:layout_alignParentRight="true"
- android:layout_alignParentTop="true"
- android:gravity="center">
- <TextView
- android:id="@+id/point"
- android:layout_width="wrap_content"
- android:layout_height="18dp"
- android:background="@drawable/red_bg"
- android:gravity="center"
- android:singleLine="true"
- android:textColor="@android:color/white"
- android:textSize="12sp"/>
- </LinearLayout>
- </RelativeLayout>
效果如下,要注意的是,對比QQ的真實體驗,小紅點周邊范圍點擊的時候,都是可以直接拖拽小紅點的??紤]到紅點的點擊范圍比較小,所以給紅點增加了一個寬高80dp的父layout,然后我們將touch小紅點事件更改為touch小紅點父layout,這樣只要我們點擊了小紅點的父layout范圍,都會添加GooView到WindowManager中。
接下來第二步,我們完成添加GooView到WindowManager中的代碼。
由于我們的GooView初始添加是從listViewItem中紅點的touch事件開始的,所以我們先完成listView adapter的實現(xiàn)。
- public class GooViewAapter extends BaseAdapter {
- private Context mContext;
- //記錄已經(jīng)remove的position
- private HashSet<Integer> mRemoved = new HashSet<Integer>();
- private List<String> list = new ArrayList<String>();
- public GooViewAapter(Context mContext, List<String> list) {
- super();
- this.mContext = mContext;
- this.list = list;
- }
- @Override
- public int getCount() {
- return list.size();
- }
- @Override
- public Object getItem(int position) {
- return list.get(position);
- }
- @Override
- public long getItemId(int position) {
- return position;
- }
- @Override
- public View getView(final int position, View convertView, ViewGroup parent) {
- if (convertView == null) {
- convertView = View.inflate(mContext, R.layout.list_item_goo, null);
- }
- ViewHolder holder = ViewHolder.getHolder(convertView);
- holder.mContent.setText(list.get(position));
- //item固定小紅點layout
- LinearLayout pointLayout = holder.mPointLayout;
- //item固定小紅點
- final TextView point = holder.mPoint;
- boolean visiable = !mRemoved.contains(position);
- pointLayout.setVisibility(visiable ? View.VISIBLE : View.GONE);
- if (visiable) {
- point.setText(String.valueOf(position));
- pointLayout.setTag(position);
- GooViewListener mGooListener = new GooViewListener(mContext, pointLayout) {
- @Override
- public void onDisappear(PointF mDragCenter) {
- super.onDisappear(mDragCenter);
- mRemoved.add(position);
- notifyDataSetChanged();
- Utils.showToast(mContext, "position " + position + " disappear.");
- }
- @Override
- public void onReset(boolean isOutOfRange) {
- super.onReset(isOutOfRange);
- notifyDataSetChanged();//刷新ListView
- Utils.showToast(mContext, "position " + position + " reset.");
- }
- };
- //在point父布局內(nèi)的觸碰事件都進行監(jiān)聽
- pointLayout.setOnTouchListener(mGooListener);
- }
- return convertView;
- }
- static class ViewHolder {
- public ImageView mImage;
- public TextView mPoint;
- public LinearLayout mPointLayout;
- public TextView mContent;
- public ViewHolder(View convertView) {
- mImage = (ImageView) convertView.findViewById(R.id.iv_head);
- mPoint = (TextView) convertView.findViewById(R.id.point);
- mPointLayout = (LinearLayout) convertView.findViewById(R.id.ll_point);
- mContent = (TextView) convertView.findViewById(R.id.tv_content);
- }
- public static ViewHolder getHolder(View convertView) {
- ViewHolder holder = (ViewHolder) convertView.getTag();
- if (holder == null) {
- holder = new ViewHolder(convertView);
- convertView.setTag(holder);
- }
- return holder;
- }
- }
- }
由于listview需要知道GooView的狀態(tài),所以我們在GooView中增加一個接口,用于listView回調(diào)處理后續(xù)的邏輯。
- interface OnDisappearListener {
- /**
- * GooView Disapper
- *
- * @param mDragCenter
- */
- void onDisappear(PointF mDragCenter);
- /**
- * GooView onReset
- *
- * @param isOutOfRange
- */
- void onReset(boolean isOutOfRange);
- }
新建一個實現(xiàn)了OnTouchListener以及OnDisappearListener 方法的的類,***將這個實現(xiàn)類設置給item中的紅點Layout。
- public class GooViewListener implements OnTouchListener, OnDisappearListener {
- private WindowManager mWm;
- private WindowManager.LayoutParams mParams;
- private GooView mGooView;
- private View pointLayout;
- private int number;
- private final Context mContext;
- private Handler mHandler;
- public GooViewListener(Context mContext, View pointLayout) {
- this.mContext = mContext;
- this.pointLayout = pointLayout;
- this.number = (Integer) pointLayout.getTag();
- mGooView = new GooView(mContext);
- mWm = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
- mParams = new WindowManager.LayoutParams();
- mParams.format = PixelFormat.TRANSLUCENT;//使窗口支持透明度
- mHandler = new Handler(mContext.getMainLooper());
- }
- @Override
- public boolean onTouch(View v, MotionEvent event) {
- int action = MotionEventCompat.getActionMasked(event);
- // 當按下時,將自定義View添加到WindowManager中
- if (action == MotionEvent.ACTION_DOWN) {
- ViewParent parent = v.getParent();
- // 請求其父級View不攔截Touch事件
- parent.requestDisallowInterceptTouchEvent(true);
- int[] points = new int[2];
- //獲取pointLayout在屏幕中的位置(layout的左上角坐標)
- pointLayout.getLocationInWindow(points);
- //獲取初始小紅點中心坐標
- int x = points[0] + pointLayout.getWidth() / 2;
- int y = points[1] + pointLayout.getHeight() / 2;
- // 初始化當前點擊的item的信息,數(shù)字及坐標
- mGooView.setStatusBarHeight(Utils.getStatusBarHeight(v));
- mGooView.setNumber(number);
- mGooView.initCenter(x, y);
- //設置當前GooView消失監(jiān)聽
- mGooView.setOnDisappearListener(this);
- // 添加當前GooView到WindowManager
- mWm.addView(mGooView, mParams);
- pointLayout.setVisibility(View.INVISIBLE);
- }
- // 將所有touch事件轉(zhuǎn)交給GooView處理
- mGooView.onTouchEvent(event);
- return true;
- }
- @Override
- public void onDisappear(PointF mDragCenter) {
- //disappear 下一步完成
- }
- @Override
- public void onReset(boolean isOutOfRange) {
- // 當dragPoint彈回時,去除該View,等下次ACTION_DOWN的時候再添加
- if (mWm != null && mGooView.getParent() != null) {
- mWm.removeView(mGooView);
- }
- }
- }
這樣下來,我們基本上完成了大部分功能,現(xiàn)在還差***一步,就是GooView超出范圍消失后的處理,這里我們用一個幀動畫來完成爆炸效果。
- public class BubbleLayout extends FrameLayout {
- Context context;
- public BubbleLayout(Context context) {
- super(context);
- this.context = context;
- }
- private int mCenterX, mCenterY;
- public void setCenter(int x, int y) {
- mCenterX = x;
- mCenterY = y;
- requestLayout();
- }
- @Override
- protected void onLayout(boolean changed, int left, int top, int right,
- int bottom) {
- View child = getChildAt(0);
- // 設置View到指定位置
- if (child != null && child.getVisibility() != GONE) {
- final int width = child.getMeasuredWidth();
- final int height = child.getMeasuredHeight();
- child.layout((int) (mCenterX - width / 2.0f), (int) (mCenterY - height / 2.0f)
- , (int) (mCenterX + width / 2.0f), (int) (mCenterY + height / 2.0f));
- }
- }
- }
- @Override
- public void onDisappear(PointF mDragCenter) {
- if (mWm != null && mGooView.getParent() != null) {
- mWm.removeView(mGooView);
- //播放氣泡爆炸動畫
- ImageView imageView = new ImageView(mContext);
- imageView.setImageResource(R.drawable.anim_bubble_pop);
- AnimationDrawable mAnimDrawable = (AnimationDrawable) imageView
- .getDrawable();
- final BubbleLayout bubbleLayout = new BubbleLayout(mContext);
- bubbleLayout.setCenter((int) mDragCenter.x, (int) mDragCenter.y - Utils.getStatusBarHeight(mGooView));
- bubbleLayout.addView(imageView, new FrameLayout.LayoutParams(
- android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
- android.widget.FrameLayout.LayoutParams.WRAP_CONTENT));
- mWm.addView(bubbleLayout, mParams);
- mAnimDrawable.start();
- // 播放結(jié)束后,刪除該bubbleLayout
- mHandler.postDelayed(new Runnable() {
- @Override
- public void run() {
- mWm.removeView(bubbleLayout);
- }
- }, 501);
- }
- }
***附上完整demo地址:https://github.com/Horrarndoo/GooView