Android开发AndroidAndroid开发经验谈

仿QQ未读消息的动画效果

2018-12-18  本文已影响12人  Tom_Ji

仿QQ未读消息的动画效果

虽然目前使用QQ的人不是很多了,尤其是工作以后,大家基本上都是在使用微信,但是QQ未读消息的动画效果还是很好的,之前一直想自己实现下这个效果,终于抽时间动手实现了。这篇用来记录自己是如何一步一步实现的。

1.绘制红色圆点和未读消息数字

先抛开拖拽的动态效果,最直观的展示就是一个小红点里面有未读数字,如图所示。

QQ未读消息效果.PNG

实现这个红色圆圈和数字的效果,效果图如图所示:

静态红色圆点效果.png

实现的代码如下UnReadRedPoint.java

package com.tom.unreadmessageview.view;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.Rect;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import com.blankj.utilcode.util.SizeUtils;

/**
 * <p>Title: UnReadRedPoint</p>
 * <p>Description: 未读信息小红点,可以设置未读的数字</p>
 *
 * @author tom
 * @date 2018/12/17 11:18
 **/
public class UnReadRedPoint extends View {

    private Context mContext;
    //屏幕中心点的XY坐标
    private float mCenterX, mCenterY;
    //未读消息的圆点的圆心坐标
    private PointF mUnReadCenter;
    //未读消息的圆点的半径
    private float mUnReadRadius;
    //未读消息数目
    private int mUnReadCount;
    //画笔
    private Paint mPaint;
    
    public UnReadRedPoint(Context context) {
        this(context, null);
    }

    public UnReadRedPoint(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public UnReadRedPoint(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;
        initResource();
    }

    /**
     * <p>初始化相关的资源参数</p>
     */
    private void initResource() {

        mUnReadRadius = SizeUtils.dp2px(20);

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        mCenterX = width / 2;
        mCenterY = height / 2;
        setUnReadCenter(new PointF(mCenterX, mCenterY));

    }

    public int getUnReadCount() {
        return mUnReadCount;
    }

    public void setUnReadCount(int unReadCount) {
        mUnReadCount = unReadCount;
    }

    public PointF getUnReadCenter() {
        return mUnReadCenter;
    }

    public void setUnReadCenter(PointF unReadCenter) {
        mUnReadCenter = unReadCenter;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //设定画笔抗锯齿
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //设置画笔的填充方式
        mPaint.setStyle(Paint.Style.FILL);
        //设置画笔颜色
        mPaint.setColor(Color.RED);
        //画圆
        canvas.drawCircle(mUnReadCenter.x, mUnReadCenter.y, mUnReadRadius, mPaint);
        //画笔设置为黑色
        mPaint.setColor(Color.BLACK);
        //辅助线
        canvas.drawLine(0, mCenterY, mCenterX * 2, mCenterY, mPaint);
        canvas.drawLine(mCenterX, 0, mCenterX, mCenterY * 2, mPaint);
        //设置文字大小
        mPaint.setTextSize(SizeUtils.sp2px(18));
        //设置文字居中
        String s = String.valueOf(mUnReadCount);
        mPaint.setTextAlign(Paint.Align.CENTER);
        Rect bounds = new Rect();
        mPaint.getTextBounds(s, 0, s.length(), bounds);
        //绘制文字
        canvas.drawText(s, mUnReadCenter.x - bounds.left / 2f, mUnReadCenter.y - bounds.centerY(), mPaint);

    }

}

布局文件比较简单,但是还是贴出来吧,文件名为activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    >


    <com.tom.unreadmessageview.view.UnReadRedPoint
        android:id="@+id/unread_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        />



</android.support.constraint.ConstraintLayout>

代码中的使用如下MainActivity.java


package com.tom.unreadmessageview;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;

import com.tom.unreadmessageview.view.UnReadRedPoint;

import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.Unbinder;

public class MainActivity extends AppCompatActivity {


    Unbinder mBinder;
    @BindView(R.id.unread_view)
    UnReadRedPoint mUnreadView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBinder = ButterKnife.bind(this);
    }


    @Override
    protected void onResume() {

        super.onResume();
        //设置未读数目
        mUnreadView.setUnReadCount(0);

    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mBinder.unbind();
    }

}

这里使用了ButterKnife这个插件,这个插件的配置和使用方法都比较简单,把gradle文件的dependencies块里面添加如下代码就可以了


implementation 'com.jakewharton:butterknife:9.0.0-rc2'
annotationProcessor 'com.jakewharton:butterknife-compiler:9.0.0-rc2'

这样,第一步就算完成了。

2.实现移动圆跟随手指移动

跟随手指拖动效果.gif

跟随手指移动的效果这里需要有一点要注意的地方,只有手指按下的位置在移动圆内,才执行效果,否则不执行。这里用到一个工具了,用来计算各种和数学相关的内容,直接拿来主义,工具类代码如下GeometryUtil

package com.tom.unreadmessageview.utils;

import android.graphics.PointF;

/**
 * <p>Title: GeometryUtil</p>
 * <p>Description: 坐标计算的相关方法</p>
 *
 * @author tom
 * @date 2018/12/12 16:28
 **/
public class GeometryUtil {

    /**
     * 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.
     * 根据百分比获取两点之间的某个点坐标
     *
     * @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));
    }

    /**
     * 根据分度值,计算从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;
    }
}

在自定义View的类UnReadRedPoint新增两个变量,用来记录按下的时候的坐标

//按下的点的坐标
private float mDownX, mDownY;

重写onTouchEvent方法,采用的是将移动圆的圆心坐标修改为手指的坐标,然后重绘,进而实现跟随手指移动的效果。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownX = event.getX();
                mDownY = event.getY();
                PointF downPoint = new PointF(mDownX, mDownY);
                //只有在按下的位置是移动圆的位置的时候,才继续执行
                if (GeometryUtil.getDistanceBetween2Points(downPoint, mUnReadCenter) < mUnReadRadius + 2f) {

                    setUnReadCenter(downPoint);
                    invalidate();
                }
                else {
                    return false;
                }

                break;
            case MotionEvent.ACTION_MOVE:
                mDownX = event.getX();
                mDownY = event.getY();
                setUnReadCenter(new PointF(mDownX, mDownY));
                invalidate();

                break;

        }
        return true;
    }

3.实现固定圆跟随移动圆的距离而变小的效果

实现随着移动圆远离最初位置(这里为屏幕中心点),固定圆的半径逐渐变小直到变为最小值,当离开最大范围时,固定圆消失。实现效果如图。

添加固定圆.gif

需要新定义一些变量,包括固定圆的圆心,最大的范围,固定圆的半径等。

    //固定圆的圆心
    private PointF mFixedCenter;
    //固定圆的半径
    private float mFixedRadius;
    //移动点距离固定点(屏幕中心)的距离
    private float mDistanceBetweenMoveAndFixed;
    //有拖拽效果的最大范围
    private float mMaxRadius;

onDrawonTouchEvent方法中,需要添加绘制固定圆的方法,同时要计算移动圆与固定圆的距离,当距离大于最大范围时,则固定圆消失,否则固定圆半径按照一定比例减小。此时的onDraw方法如下:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //设定画笔抗锯齿
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //设置画笔的填充方式
        mPaint.setStyle(Paint.Style.FILL);
        //设置画笔颜色
        mPaint.setColor(Color.RED);
        //画固定圆
        mPaint.setColor(Color.BLUE);
        //固定圆的半径
        //如果手指和固定圆圆心的距离大于最大半径(范围外),则不需要画出固定圆
        if (mDistanceBetweenMoveAndFixed - mUnReadRadius> mMaxRadius) {
            mFixedRadius = 0;
        }
        else {

            mFixedRadius = SizeUtils.dp2px(15) - SizeUtils.dp2px(8) * mDistanceBetweenMoveAndFixed / mMaxRadius;
        }
        canvas.drawCircle(mFixedCenter.x, mFixedCenter.y, mFixedRadius, mPaint);
        //画移动圆
        mPaint.setColor(Color.RED);
        canvas.drawCircle(mUnReadCenter.x, mUnReadCenter.y, mUnReadRadius, mPaint);
        //画笔设置为黑色
        mPaint.setColor(Color.BLACK);
        //设置画笔的填充方式
        mPaint.setStyle(Paint.Style.STROKE);
        //辅助线
        canvas.drawLine(0, mCenterY, mCenterX * 2, mCenterY, mPaint);
        canvas.drawLine(mCenterX, 0, mCenterX, mCenterY * 2, mPaint);
        canvas.drawCircle(mCenterX, mCenterY, mMaxRadius, mPaint);
        //设置文字大小
        mPaint.setTextSize(SizeUtils.sp2px(18));
        //设置文字居中
        String s = String.valueOf(mUnReadCount);
        mPaint.setTextAlign(Paint.Align.CENTER);
        mPaint.setStyle(Paint.Style.FILL);
        Rect bounds = new Rect();
        mPaint.getTextBounds(s, 0, s.length(), bounds);
        //绘制文字
        canvas.drawText(s, mUnReadCenter.x - bounds.left / 2f, mUnReadCenter.y - bounds.centerY(), mPaint);

    }

onTouchEvent方法如下:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownX = event.getX();
                mDownY = event.getY();
                PointF downPoint = new PointF(mDownX, mDownY);
                //只有在按下的位置是移动圆的位置的时候,才继续执行
                if (GeometryUtil.getDistanceBetween2Points(downPoint, mUnReadCenter) < mUnReadRadius + 2f) {

                    setUnReadCenter(downPoint);
                    invalidate();
                }
                else {
                    return false;
                }

                break;
            case MotionEvent.ACTION_MOVE:
                mDownX = event.getX();
                mDownY = event.getY();
                PointF movePointF = new PointF(mDownX, mDownY);
                setUnReadCenter(movePointF);
                mDistanceBetweenMoveAndFixed = GeometryUtil.getDistanceBetween2Points(mFixedCenter, movePointF);
                invalidate();

                break;

        }
        return true;
    }

4.绘制贝塞尔曲线

两圆之间的效果是通过绘制贝塞尔曲线实现的,关于绘制贝塞尔曲线的相关内容,我也是参考网上的内容,这一篇文章贝塞尔曲线能提供很详细的内容。先奉上实现的效果图。

添加贝塞尔曲线效果.gif

这里面会涉及到贝塞尔曲线的相关知识,不想了解的直接复制下面的代码就可以了。具体的如何绘制,可以一步一步的调试来理解这个效果的实现。

    /**
     * <p>绘制贝塞尔曲线</p>
     *
     * @param canvas
     */
    private void drawBezier(Canvas canvas) {
        //设置画笔属性
        mPaint.setColor(Color.RED);
        //获取两点连线的中点
        PointF middlePoint = GeometryUtil.getMiddlePoint(mUnReadCenter, mFixedCenter);
        //计算两圆圆心连线的斜率
        float offSetY = mUnReadCenter.y - mFixedCenter.y;
        float offSetX = mUnReadCenter.x - mFixedCenter.x;
        Double lineK = null;
        if (offSetX != 0) {
            lineK = Double.valueOf(offSetY / offSetX);
        }
        else {
            LogUtils.d("没有斜率,为直线");
        }

        //得到固定圆的附着点
        PointF[] fixedPoints = GeometryUtil.getIntersectionPoints(mFixedCenter, mFixedRadius, lineK);
        PointF[] unReaderPoints = GeometryUtil.getIntersectionPoints(mUnReadCenter, mUnReadRadius, lineK);
        //画出辅助点
        canvas.drawPoint(fixedPoints[0].x, fixedPoints[0].y, mPaint);
        //绘制路径
        //两圆连线的中点为曲线的控制点,同侧的附着点为固定点
        Path path = new Path();
        path.moveTo(fixedPoints[0].x, fixedPoints[0].y);
        //画出一半贝塞尔曲线
        path.quadTo(middlePoint.x, middlePoint.y, unReaderPoints[0].x, unReaderPoints[0].y);
        //连接两点
        path.lineTo(unReaderPoints[1].x, unReaderPoints[1].y);
        //画出另一半贝塞尔曲线
        path.quadTo(middlePoint.x, middlePoint.y, fixedPoints[1].x, fixedPoints[1].y);
        //闭合路径
        path.close();
        //根据设置的路径作画
        canvas.drawPath(path, mPaint);
    }

5.实现回弹效果

回弹需要是手指抬起时进行处理,也就需要在onTouchEvent中,添加MotionEvent.ACTION_UP的处理。需要判断抬起时,抬起位置是否在最大范围内,如果在最大范围内,则需要实现回弹效果,否则不需要。这里新定义了一些变量,会在最后贴出完整代码。

添加回弹效果.gif

此效果的关键代码为动画。

   /**
     * <p>回弹动画</p>
     */
    private void startBackAnim() {
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(1.0f);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float animatedFraction = animation.getAnimatedFraction();
                //获取手指离开的距离
                PointF pointByPercent = GeometryUtil.getPointByPercent(mUpPointF, mFixedCenter, animatedFraction);
                //动态设定距离
                setUnReadCenter(pointByPercent);
                invalidate();
            }
        });

        valueAnimator.setInterpolator(new TimeInterpolator() {
            @Override
            public float getInterpolation(float input) {
                float f = 0.571429f;
                return (float) (Math.pow(2, -4 * input) * Math.sin((input - f / 4) * (2 * Math.PI) / f) + 1);
            }
        });
        valueAnimator.setDuration(300);
        valueAnimator.start();

    }

这里用到了值动画还有插值器,相关的内容就自己学习,资料很多。

6.超出范围爆炸效果

手指抬起的位置如果是在范围之外,是有一个爆炸消失的效果,接下来添加这个效果,需要用到4个资源图片,文末会附上,也可以使用其他的图片资源。

消失爆炸效果.gif

这里调整了一下代码的格式,至此已经完成了需要实现的动画效果,附上动画的代码。


    /**
     * <p>消失动画效果</p>
     */
    private void startDismissAnim() {
        ValueAnimator animatorDismiss = ValueAnimator.ofInt(0, mExplodes.length);
        animatorDismiss.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mExplodeIndex = (int) animation.getAnimatedValue();
                invalidate();
            }
        });
        animatorDismiss.setDuration(300);
        animatorDismiss.start();
    }

7.其他细节部分

貌似一看,效果已经实现了,但是还有一些细节需要处理。比如当移动圆离开最大范围再回到范围内时,是没有贝塞尔曲线和回弹效果的,这里可以自行对比QQ,还可以加上回调,用来处理不同状态的情况。最终效果:


完整效果.gif

最后附上完整的代码:
布局文件activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    >

    <com.tom.unreadmessageview.view.UnReadRedPoint
        android:id="@+id/unread_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        />

</android.support.constraint.ConstraintLayout>

主Activity文件MainActivity.java

package com.tom.unreadmessageview;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;

import com.tom.unreadmessageview.utils.ToastUtils;
import com.tom.unreadmessageview.view.UnReadRedPoint;

import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.Unbinder;

public class MainActivity extends AppCompatActivity {


    Unbinder mBinder;
    @BindView(R.id.unread_view)
    UnReadRedPoint mUnreadView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBinder = ButterKnife.bind(this);
    }


    @Override
    protected void onResume() {

        super.onResume();
        //设置未读数目
        mUnreadView.setUnReadCount(0);
        mUnreadView.setOnUnReadRedPointListener(new UnReadRedPoint.OnUnReadRedPointListener() {
            @Override
            public void onDismiss() {
                ToastUtils.setToast(MainActivity.this,"消失了");
            }
        });

    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mBinder.unbind();
    }

}

自定义View UnReadRedPoint.java

package com.tom.unreadmessageview.view;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.TimeInterpolator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.Rect;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.OvershootInterpolator;

import com.blankj.utilcode.util.LogUtils;
import com.blankj.utilcode.util.SizeUtils;
import com.tom.unreadmessageview.R;
import com.tom.unreadmessageview.utils.GeometryUtil;

/**
 * <p>Title: UnReadRedPoint</p>
 * <p>Description: 未读信息小红点,可以设置未读的数字</p>
 *
 * @author tom
 * @date 2018/12/17 11:18
 **/
public class UnReadRedPoint extends View {

    private static final int RADIUS_UNREAD = 15;
    private static final int RADIUS_FIXED = 12;
    private static final int RADIUS_MIN = 6;
    private static final int RADIUS_MAX = 120;
    private static final int SIZE_TEXT = 16;

    private Context mContext;
    //屏幕中心点的XY坐标
    private float mCenterX, mCenterY;
    //未读消息的圆点的圆心坐标
    private PointF mUnReadCenter;
    //未读消息的圆点的半径
    private float mUnReadRadius;
    //未读消息数目
    private int mUnReadCount;
    //画笔
    private Paint mPaint;
    //按下的点的坐标
    private float mDownX, mDownY;
    //固定圆的圆心
    private PointF mFixedCenter;
    //固定圆的半径
    private float mFixedRadius;
    //移动点距离固定点(屏幕中心)的距离
    private float mDistanceBetweenMoveAndFixed;
    //有拖拽效果的最大范围
    private float mMaxRadius;
    //抬起点的坐标
    private PointF mUpPointF;
    //抬起点和固定圆的距离
    private float mDistanceBetweenUPAndFixed;
    //爆炸动画相关
    private int mExplodeIndex;
    private int[] mExplodes = {R.mipmap.explode1, R.mipmap.explode2, R.mipmap.explode3, R.mipmap.explode4, R.mipmap
            .explode5};
    private Bitmap[] mExplodeBitmaps;
    //是否超出过范围
    private boolean isBeyond = false;

    //回调监听接口
    private OnUnReadRedPointListener mOnUnReadRedPointListener;


    public UnReadRedPoint(Context context) {
        this(context, null);
    }

    public UnReadRedPoint(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public UnReadRedPoint(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;
        initResource();
    }

    /**
     * <p>初始化相关的资源参数</p>
     */
    private void initResource() {

        mUnReadRadius = SizeUtils.dp2px(RADIUS_UNREAD);
        mFixedRadius = SizeUtils.dp2px(RADIUS_FIXED);
        mMaxRadius = SizeUtils.dp2px(RADIUS_MAX);

        mExplodeBitmaps = new Bitmap[mExplodes.length];
        for (int i = 0; i < mExplodes.length; i++) {
            mExplodeBitmaps[i] = BitmapFactory.decodeResource(getResources(), mExplodes[i]);
        }

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        mCenterX = width / 2;
        mCenterY = height / 2;
        PointF centerPointF = new PointF(mCenterX, mCenterY);
        setUnReadCenter(centerPointF);
        setFixedCenter(centerPointF);

    }

    public int getUnReadCount() {
        return mUnReadCount;
    }

    public void setUnReadCount(int unReadCount) {
        mUnReadCount = unReadCount;
    }

    public PointF getUnReadCenter() {
        return mUnReadCenter;
    }

    public void setUnReadCenter(PointF unReadCenter) {
        mUnReadCenter = unReadCenter;
    }

    public PointF getFixedCenter() {
        return mFixedCenter;
    }

    public void setFixedCenter(PointF fixedCenter) {
        mFixedCenter = fixedCenter;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //设定画笔抗锯齿
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //画笔设置为黑色
        mPaint.setColor(Color.BLACK);
        //设置画笔的填充方式
        mPaint.setStyle(Paint.Style.STROKE);
        //辅助线
        canvas.drawLine(0, mCenterY, mCenterX * 2, mCenterY, mPaint);
        canvas.drawLine(mCenterX, 0, mCenterX, mCenterY * 2, mPaint);
        canvas.drawCircle(mCenterX, mCenterY, mMaxRadius, mPaint);
        //设置画笔的填充方式
        mPaint.setStyle(Paint.Style.FILL);
        //设置画笔颜色
        mPaint.setColor(Color.RED);
        //画固定圆
        //固定圆的半径
        //如果手指和固定圆圆心的距离大于最大半径(范围外),则不需要画出固定圆
        if (mDistanceBetweenMoveAndFixed - mUnReadRadius > mMaxRadius) {
            mFixedRadius = 0;
        }
        else {

            mFixedRadius = SizeUtils.dp2px(RADIUS_FIXED) - SizeUtils.dp2px(RADIUS_MIN) * mDistanceBetweenMoveAndFixed
                    / mMaxRadius;
            //绘制贝塞尔曲线
            if (!isBeyond) {

                drawBezier(canvas);
            }
            //画固定圆
            if (!isBeyond) {

                canvas.drawCircle(mFixedCenter.x, mFixedCenter.y, mFixedRadius, mPaint);
            }
        }

        if (mDistanceBetweenUPAndFixed - mUnReadRadius > mMaxRadius) {

            if (mExplodeIndex < mExplodes.length) {
                canvas.drawBitmap(mExplodeBitmaps[mExplodeIndex], mUnReadCenter.x, mUnReadCenter.y, mPaint);
            }

        }
        else {
            //画移动圆
            canvas.drawCircle(mUnReadCenter.x, mUnReadCenter.y, mUnReadRadius, mPaint);
            //设置文字大小
            mPaint.setTextSize(SizeUtils.sp2px(SIZE_TEXT));
            //设置画笔颜色
            mPaint.setColor(Color.BLACK);
            //设置文字居中
            String s = String.valueOf(mUnReadCount);
            mPaint.setTextAlign(Paint.Align.CENTER);
            mPaint.setStyle(Paint.Style.FILL);
            Rect bounds = new Rect();
            mPaint.getTextBounds(s, 0, s.length(), bounds);
            //绘制文字
            canvas.drawText(s, mUnReadCenter.x - bounds.left / 2f, mUnReadCenter.y - bounds.centerY(), mPaint);

        }

    }

    /**
     * <p>绘制贝塞尔曲线</p>
     *
     * @param canvas
     */
    private void drawBezier(Canvas canvas) {
        //设置画笔属性
        mPaint.setColor(Color.RED);
        //获取两点连线的中点
        PointF middlePoint = GeometryUtil.getMiddlePoint(mUnReadCenter, mFixedCenter);
        //计算两圆圆心连线的斜率
        float offSetY = mUnReadCenter.y - mFixedCenter.y;
        float offSetX = mUnReadCenter.x - mFixedCenter.x;
        Double lineK = null;
        if (offSetX != 0) {
            lineK = Double.valueOf(offSetY / offSetX);
        }
        else {
            LogUtils.d("没有斜率,为直线");
        }

        //得到固定圆的附着点
        PointF[] fixedPoints = GeometryUtil.getIntersectionPoints(mFixedCenter, mFixedRadius, lineK);
        PointF[] unReaderPoints = GeometryUtil.getIntersectionPoints(mUnReadCenter, mUnReadRadius, lineK);
        //绘制路径
        //两圆连线的中点为曲线的控制点,同侧的附着点为固定点
        Path path = new Path();
        path.moveTo(fixedPoints[0].x, fixedPoints[0].y);
        //画出一半贝塞尔曲线
        path.quadTo(middlePoint.x, middlePoint.y, unReaderPoints[0].x, unReaderPoints[0].y);
        //连接两点
        path.lineTo(unReaderPoints[1].x, unReaderPoints[1].y);
        //画出另一半贝塞尔曲线
        path.quadTo(middlePoint.x, middlePoint.y, fixedPoints[1].x, fixedPoints[1].y);
        //闭合路径
        path.close();
        //根据设置的路径作画
        canvas.drawPath(path, mPaint);
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownX = event.getX();
                mDownY = event.getY();
                PointF downPoint = new PointF(mDownX, mDownY);
                //只有在按下的位置是移动圆的位置的时候,才继续执行
                if (GeometryUtil.getDistanceBetween2Points(downPoint, mUnReadCenter) < mUnReadRadius + 2f) {

                    setUnReadCenter(downPoint);
                    invalidate();
                }
                else {
                    return false;
                }

                break;
            case MotionEvent.ACTION_MOVE:
                mDownX = event.getX();
                mDownY = event.getY();
                PointF movePointF = new PointF(mDownX, mDownY);
                setUnReadCenter(movePointF);
                mDistanceBetweenMoveAndFixed = GeometryUtil.getDistanceBetween2Points(mFixedCenter, movePointF);
                if (mDistanceBetweenMoveAndFixed > mMaxRadius) {
                    isBeyond = true;
                }
                invalidate();

                break;
            case MotionEvent.ACTION_UP:
                float upX = event.getX();
                float upY = event.getY();
                mUpPointF = new PointF(upX, upY);
                mDistanceBetweenUPAndFixed = GeometryUtil.getDistanceBetween2Points(mFixedCenter, mUpPointF);
                //抬起点在最大范围之内
                if (mDistanceBetweenUPAndFixed - mUnReadRadius <= mMaxRadius) {
                    //开始动画
                    startBackAnim();
                    invalidate();

                }
                else {
                    // 抬起点在最大范围之外
                    // 开始爆炸效果动画
                    startDismissAnim();
                    invalidate();
                    isBeyond = false;
                    setDismiss();
                }

                break;
        }
        return true;
    }

    /**
     * <p>消失动画效果</p>
     */
    private void startDismissAnim() {
        ValueAnimator animatorDismiss = ValueAnimator.ofInt(0, mExplodes.length);
        animatorDismiss.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mExplodeIndex = (int) animation.getAnimatedValue();
                invalidate();
            }
        });
        animatorDismiss.setDuration(300);
        animatorDismiss.start();
    }

    /**
     * <p>回弹动画</p>
     */
    private void startBackAnim() {
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(1.0f);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float animatedFraction = animation.getAnimatedFraction();
                //获取手指离开的距离
                PointF pointByPercent = GeometryUtil.getPointByPercent(mUpPointF, mFixedCenter, animatedFraction);
                //动态设定距离
                setUnReadCenter(pointByPercent);
                invalidate();
            }
        });

        valueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                isBeyond = false;
            }
        });

        if (!isBeyond) {
            valueAnimator.setInterpolator(new TimeInterpolator() {
                @Override
                public float getInterpolation(float input) {
                    float f = 0.571429f;
                    return (float) (Math.pow(2, -4 * input) * Math.sin((input - f / 4) * (2 * Math.PI) / f) + 1);
                }
            });

            valueAnimator.setDuration(300);
        }
        else {
            valueAnimator.setInterpolator(new OvershootInterpolator(1));
            valueAnimator.setDuration(50);
        }

        valueAnimator.start();

    }

    /**
     * <p>定义接口</p>
     */
    public interface OnUnReadRedPointListener {
        void onDismiss();
    }

    public void setOnUnReadRedPointListener(OnUnReadRedPointListener onUnReadRedPointListener) {
        mOnUnReadRedPointListener = onUnReadRedPointListener;
    }

    public void setDismiss() {
        if (mOnUnReadRedPointListener != null) {
            mOnUnReadRedPointListener.onDismiss();
        }
    }
}

工具类 GeometryUtil.java

package com.tom.unreadmessageview.utils;

import android.graphics.PointF;

/**
 * <p>Title: GeometryUtil</p>
 * <p>Description: 坐标计算的相关方法</p>
 *
 * @author tom
 * @date 2018/12/12 16:28
 **/
public class GeometryUtil {

    /**
     * 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.
     * 根据百分比获取两点之间的某个点坐标
     *
     * @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));
    }

    /**
     * 根据分度值,计算从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;
    }
}

ToastUtils.java

package com.tom.unreadmessageview.utils;

import android.content.Context;
import android.widget.Toast;

/**
 * <p>Title: ToastUtils</p>
 * <p>Description: </p>
 *
 * @author tom
 * @date 2018/12/13 17:13
 **/
public class ToastUtils {
    private static Toast toast;

    public static void setToast(Context context, String msg) {
        if (toast == null) {
            toast = Toast.makeText(context, "", Toast.LENGTH_SHORT);
        }
        toast.setText(msg);
        toast.show();
    }
}

还有爆炸效果的图片文件

explode1.png explode2.png explode3.png explode4.png explode5.png

有疑问的欢迎留言共同交流学习,共同进步。

上一篇下一篇

猜你喜欢

热点阅读