欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

程序员文章站 2022-07-12 23:16:32
...

目录



先来两张动态图吸引下目光:

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

贝塞尔曲线定义

贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。贝塞尔曲线是计算机图形学中相当重要的参数曲线,在一些比较成熟的位图软件中也有贝塞尔曲线工具,如PhotoShop等。在Flash4中还没有完整的曲线工具,而在Flash5里面已经提供出贝塞尔曲线工具。

贝塞尔曲线于1962,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由Paul de Casteljau于1959年运用de Casteljau演算法开发,以稳定数值的方法求出贝兹曲线。

线性公式

给定点P0、P1,线性贝兹曲线只是一条两点之间的直线。这条线由下式给出:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

且其等同于线性插值

二次方公式

二次方贝兹曲线的路径由给定点P0、P1、P2的函数B(t)追踪:

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

TrueType字型就运用了以贝兹样条组成的二次贝兹曲线。

三次方公式

P0、P1、P2、P3四个点在平面或在三维空间中定义了三次方贝兹曲线。曲线起始于P0走向P1,并从P2的方向来到P3。一般不会经过P1或P2;这两个点只是在那里提供方向资讯。P0和P1之间的间距,决定了曲线在转而趋进P3之前,走向P2方向的“长度有多长”。
曲线的参数形式为:

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

现代的成象系统,如PostScript、Asymptote和Metafont,运用了以贝兹样条组成的三次贝兹曲线,用来描绘曲线轮廓。

一般参数公式
阶贝兹曲线可如下推断。给定点P0、P1、…、Pn,其贝兹曲线即:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

如上公式可如下递归表达: 用表示由点P0、P1、…、Pn所决定的贝兹曲线。
用平常话来说,阶的贝兹曲线,即双阶贝兹曲线之间的插值。

公式说明

1.开始于P0并结束于Pn的曲线,即所谓的端点插值法属性。
2.曲线是直线的充分必要条件是所有的控制点都位在曲线上。同样的,贝塞尔曲线是直线的充分必要条件是控制点共线。
3.曲线的起始点(结束点)相切于贝塞尔多边形的第一节(最后一节)。
4.一条曲线可在任意点切割成两条或任意多条子曲线,每一条子曲线仍是贝塞尔曲线。
5.一些看似简单的曲线(如圆)无法以贝塞尔曲线精确的描述,或分段成贝塞尔曲线(虽然当每个内部控制点对单位圆上的外部控制点水平或垂直的的距离为时,分成四段的贝兹曲线,可以小于千分之一的最大半径误差近似于圆)。
6.位于固定偏移量的曲线(来自给定的贝塞尔曲线),又称作偏移曲线(假平行于原来的曲线,如两条铁轨之间的偏移)无法以贝兹曲线精确的形成(某些琐屑实例除外)。无论如何,现存的启发法通常可为实际用途中给出近似值。

理解

我们把贝塞尔曲线是由谁创建的,基础设计初衷是什么,公式定义是什么,先过了一遍,我们可以理解为在n个点坐标之间生成一条曲线,这条曲线则称为(n - 1)阶贝塞尔曲线曲线。

概念定义我们看完了,那我们可以暂且抛开这些生硬难看的公式,我们来看一下更加形象的图片:

一阶贝塞尔曲线:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

二阶贝塞尔曲线
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

三阶贝塞尔曲线
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

四阶贝塞尔曲线
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

关于贝塞尔深入理解,可以看下这篇文章:
贝塞尔曲线扫盲

德卡斯特里奥算法

我们来了解一下德卡斯特里奥算法,参考

Finding a Point on a Bézier Curve: De Casteljau’s Algorithm

我们了解了贝塞尔曲线是由几个点之间线段,按照一定比例取对应的点组成的一条曲线,那么德卡斯特里奥算法的作用就是根据这几个点来计算出贝塞尔曲线上的坐标序列。

德卡斯特里奥算法的基本思想是在向量AB上选择一个点C,使得C分向量AB为 t:1-t(即∣AC∣:∣AB∣= t)。我们找到一种方法来确定点C。
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

向量A到B记做B-A,那么转换为实际值就是我们的Y-X的具体值,点C在T(B-A)上。以A点坐标作为起始点,点C坐标为A + t(B - A) = (1 - t)A + tB。因此给定一个t值,(1 - t)A + tB使C点分向量AB为t:1-t。

具体实际上为公式可以理解为A-C的距离比例=1-AC的比例
那么这是在一阶的情况下产生的
德卡斯特里奥算法的思想是为了让我们的多介贝塞尔曲线能够运用一个公式计算出最终的那个点如下:

设P0、P02、P2是一条抛物线上顺序三个不同的点。过P0和P2点的两切线交于P1点,在P02点的切线交P0P1和P2P1于P01和P11,则如下比例成立:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

那么P0是起点P2是终点P1是控制点
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

当t从0变到1时,它表示了由三顶点P0、P1、P2三点定义的一条二次Bezier曲线。并且表明:这二次Bezier曲线P02可以定义为分别由前两个顶点(P0,P1)和后两个顶点(P1,P2)决定的一次Bezier曲线的线性组合。依次类推,由四个控制点定义的三次Bezier曲线P03可被定义为分别由(P0,P1,P2)和(P1,P2,P3)确定的二条二次Bezier曲线的线性组合,由(n+1)个控制点Pi(i=0,1,…,n)定义的n次Bezier曲线P0n可被定义为分别由前、后n个控制点定义的两条(n-1)次Bezier曲线P0n-1与P1n-1的线性组合:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

由此得到Bezier曲线的递推计算公式
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

这就是de Casteljau算法;

写递归算法的话,核心应该是:
p(i,j) = (1-t) * p(i-1,j) + t * p(i-1,j-1)

代码实现贝塞尔曲线

好了,至此我们已经过了一遍贝塞尔曲线的定义和公式,还是一脸懵逼,好吧,上面那一堆东西只不过是循例的一个说明可以说并没什么卵用,如果对贝塞尔曲线和德卡斯特里奥算法还不清楚的话,可以把它当做是一条曲线,这条曲线根据几个坐标点按照特定的规则生成,而德卡斯特里奥算法则可以根据这些坐标帮助我们得到这条贝塞尔曲线的每一个坐标点,那么安卓里面有没有特定的贝塞尔算法的api呢?答案是有的,以下就是

//二阶贝塞尔曲线
path.quadTo(float x1, float y1, float x2, float y2)

//三阶贝塞尔曲线
path.cubicTo(float x1, float y1, float x2, float y2, float x3, float y3)

quadTo, cubicTo 分别为二阶和三阶贝塞尔曲线的方法,没错,只定义了两个方法, 那我们想实现四阶,五阶呢,这个时候就需要自定义方法来实现了

工具类代码:

public class DeCasteljau {
    private List<PointF> computePoints;

    private DeCasteljau(){
    }

    public static DeCasteljau create(){
        return new DeCasteljau();
    }

    /**
     * 配置坐标点数组
     *
     * 2个点代表一阶贝塞尔曲线
     *
     * 3个点代表二阶贝塞尔曲线
     *
     * ....
     *
     * n个点代表 (n - 1阶)贝塞尔曲线
     *
     * @param points 坐标点数组
     * */
    public DeCasteljau setPoints(List<PointF> points) {
        this.computePoints = points;
        return this;
    }

    /**
     * 执行获取曲线坐标
     *
     * @param intensity 密集程度 > 0 && < 1,设置曲线密集程度,例如1 / 100则是取100个点
     * @return 返回的为贝塞尔曲线的坐标数组
     * */
    public List<PointF> execute(float intensity){
        if (computePoints == null || computePoints.isEmpty()){
            return null;
        }

        int order = computePoints.size() - 1;
        List<PointF> returnPoints = new ArrayList<>();

        for (float t = 0; t <= 1; t += intensity){
            PointF pointF = new PointF(deCasteljauX(order, 0, t), deCasteljauY(order, 0, t));
            returnPoints.add(pointF);
        }

        return returnPoints;
    }

    /**
     *  p(i,j) =  (1-u) * p(i-1,j)  +  u * p(i-1,j-1)
     *
     * de Casteljau递归算法 获取最终的贝塞尔曲线x坐标
     *
     * @param order 阶数
     * @param index 坐标点数组下标
     * @param ratio [0 - 1] 这个为每个线段中,取点的比值
     * @return
     */
    public float deCasteljauX(int order, int index, float ratio) {
        if (order == 1) {
            return (1 - ratio) * computePoints.get(index).x + ratio * computePoints.get(index + 1).x;
        }

        //解阶
        return (1 - ratio) * deCasteljauX(order - 1, index, ratio) + ratio * deCasteljauX(order - 1, index + 1, ratio);
    }

    /**
     *  p(i,j) =  (1-u) * p(i-1,j)  +  u * p(i-1,j-1)
     *
     * de Casteljau递归算法 获取最终的贝塞尔曲线y坐标
     *
     * @param order 阶数
     * @param index 坐标点数组下标
     * @param ratio [0 - 1] 这个为每个线段中,取点的比值
     * @return
     */
    public float deCasteljauY(int order, int index, float ratio) {
        if (order == 1) {
            return (1 - ratio) * computePoints.get(index).y + ratio * computePoints.get(index + 1).y;
        }

        //解阶
        return (1 - ratio) * deCasteljauY(order - 1, index, ratio) + ratio * deCasteljauY(order - 1, index + 1, ratio);
    }
}

BezerView代码

public class BezerView extends View {
    private String TAG = "BezerView";

    private List<PointF> definedPoints = new ArrayList<>(); //随机生成的坐标点
    private List<PointF> bezerPoints; //贝塞尔曲线的坐标点

    private Paint bezerCurvePaint; //贝塞尔曲线画笔
    private Paint coordinateLinePaint; //坐标点画笔
    private Paint coordinatePointPaint; //坐标间连线画笔

    private Path linePath; //几点之间的连线path
    private Path curvePath; //曲线path
    public BezerView(Context context) {
        super(context);

        init();
    }

    public BezerView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);

        init();
    }

    public BezerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        init();
    }


    private void init() {
        coordinateLinePaint = new Paint();
        coordinateLinePaint.setAntiAlias(true);
        coordinateLinePaint.setColor(Color.RED);
        coordinateLinePaint.setStrokeWidth(4);
        coordinateLinePaint.setStyle(Paint.Style.STROKE);

        coordinatePointPaint = new Paint();
        coordinatePointPaint.setAntiAlias(true);
        coordinatePointPaint.setStrokeWidth(4);
        coordinatePointPaint.setStyle(Paint.Style.STROKE);
        coordinatePointPaint.setColor(Color.RED);

        bezerCurvePaint = new Paint();
        bezerCurvePaint.setAntiAlias(true);
        bezerCurvePaint.setStrokeWidth(8);
        bezerCurvePaint.setStyle(Paint.Style.STROKE);
        bezerCurvePaint.setColor(Color.BLACK);


        linePath = new Path();
        curvePath = new Path();
    }

    //这里随机产生五个点
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        initPoints();
    }

    //初始化坐标点
    private void initPoints(){
        int width = getMeasuredWidth();
        int height = getMeasuredHeight();

        if (definedPoints.size() > 0){
            definedPoints.clear();
        }

        //这里可以自定义为几阶曲线,下面定义为四阶
        Random random = new Random();
        for (int i = 0; i < 5; i++) {
            int x = random.nextInt(width / 2) + 100;
            int y = random.nextInt(height / 2) + 100;

            PointF pointF = new PointF(x, y);
            definedPoints.add(pointF);
        }

        bezerPoints = DeCasteljau.create().setPoints(definedPoints).execute(1f / 1000f);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int size = definedPoints.size();

        linePath.reset();

        //设置默认坐标原点为第一个点
        linePath.moveTo(definedPoints.get(0).x, definedPoints.get(0).y);

        for (int i = 0; i < size; i++) {
            PointF point = definedPoints.get(i);
            linePath.lineTo(point.x, point.y); //设定到x,y的直线
            canvas.drawPath(linePath, coordinateLinePaint); //画线

            // 控制点
            canvas.drawCircle(point.x, point.y, 10, coordinatePointPaint);
        }

        if (bezerPoints == null || bezerPoints.isEmpty()){
            return;
        }

        curvePath.reset();

        //设置默认坐标原点为第一个点
        curvePath.moveTo(bezerPoints.get(0).x, bezerPoints.get(0).y);
        for (int i = 0; i < bezerPoints.size(); i++){
            curvePath.lineTo(bezerPoints.get(i).x, bezerPoints.get(i).y);
        }

        canvas.drawPath(curvePath, bezerCurvePaint);
    }

    public void refresh(){
        initPoints();
        invalidate();
    }
}

效果图:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

我们代码指定了生成的贝塞尔曲线为四阶贝塞尔曲线,可以看到随机生成了五个点,而黑色曲线则为贝塞尔曲线。

QQ气泡效果

贝塞尔曲线在上面我们已经了解了其定义和作用,现在我们来运用一下,使用贝塞尔曲线来实现QQ气泡效果
我们先看下动态图:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

为了方便在布局文件即xml文件中使用,我们先定一些常用的属性:

    <declare-styleable name="FunnyBubbleView">
        <!--半径大小-->
        <attr name="radius" format="dimension"/>
        <!--气泡颜色-->
        <attr name="color" format="color"/>
        <!--要显示的文字-->
        <attr name="text" format="string|reference"/>
        <!--文字大小-->
        <attr name="textSize" format="dimension"/>
        <!--文字颜色-->
        <attr name="textColor" format="color"/>
    </declare-styleable>

那么我们在xml中可以使用:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="@android:color/darker_gray"
    android:layout_width="match_parent" android:layout_height="match_parent">

    <com.iigo.bezier.FunnyBubbleView
        android:id="@+id/fbv_bubble"
        app:text="99+"
        app:radius="15dp"
        app:textColor="#ffffffff"
        app:textSize="15dp"
        app:color="#FFFF0000"
        android:layout_centerInParent="true"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <Button
        android:text="Reset"
        android:textAllCaps="false"
        android:onClick="onReset"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="20dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

代码中首先我们定义4种状态:

public enum State{
        DEFAULT, //默认状态即静止状态
        CONNECTING, //连接状态即范围内连接状态
        APART, //分离状态即气泡已被拖拽到范围外
        DISAPPEARING, //开始消失动画
        DISAPPEARED, //气泡已经消失
    }

第一个默认状态即为静止状态,即手指不移动时的状态;
第二个则为连接状态,这时手指已经开始移动;
第三个为分离状态,即手指移动的距离已超过一定的距离;
第四个为消失动画开始,即手指一动超过一定距离后送开手指,即开始执行消失动画;
第五个为气泡消失状态,当消失动画执行完毕,则气泡已经消失;

再梳理一下原理图:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

O1为静止圆圆心,O2则为跟随手指移动的圆圆心,因此我们可以求得两圆心之间直线的中心坐标

 float anchorX = (movableCircleCenterPoint.x + stillCircleCenterPoint.x) / 2;
 float anchorY = (movableCircleCenterPoint.y + stillCircleCenterPoint.y) / 2;

因此根据三角函数,绿色角θ的正弦和余弦值分别为:

float sin = (movableCircleCenterPoint.y - stillCircleCenterPoint.y) / distance;
float cos = (movableCircleCenterPoint.x - stillCircleCenterPoint.x) / distance;

我们又知道静止圆和可移动圆中,AB线和CD线平行,因此上图中绿色的角度都相等,这时我们可以获取到A,B,C,D四个点的坐标:

//静止气泡的开始点和结束点坐标
float stillBubbleStartX = stillCircleCenterPoint.x - sin * stillBubbleRadius;
float stillBubbleStartY = stillCircleCenterPoint.y + cos * stillBubbleRadius;
float stillBubbleEndX = stillCircleCenterPoint.x + sin * stillBubbleRadius;
float stillBubbleEndY = stillCircleCenterPoint.y - cos * stillBubbleRadius;


//可移动气泡的开始点和结束点坐标
float movableBubbleStartX = movableCircleCenterPoint.x - movableBubbleRadius * sin;
float movableBubbleStartY = movableCircleCenterPoint.y + movableBubbleRadius * cos;
float movableBubbleEndX = movableCircleCenterPoint.x + movableBubbleRadius * sin;
float movableBubbleEndY = movableCircleCenterPoint.y - movableBubbleRadius * cos;

代码+说明:

public class FunnyBubbleView extends View {
    /**
     * 气泡的状态
     * */
    public enum State{
        DEFAULT, //默认状态即静止状态
        CONNECTING, //连接状态即范围内连接状态
        APART, //分离状态即气泡已被拖拽到范围外
        DISAPPEARING, //开始消失动画
        DISAPPEARED, //气泡已经消失
    }

    private final int MAX_DISTANCE_MULTIPLE_OF_RADIUS = 8; //可移动距离相对于可移动气泡半径的倍数即movableBubbleRadius * 8
    private final int MOVABLE_DISTANCE_RATIO_OF_MAX_DISTANCE = 4; //当状态为default的时候,当手指按下的点离圆(非圆心,圆边坐标)的多长的距离可开始移动,这里设置的距离为最大距离的多少分之一,即 MAX_DISTANCE_MULTIPLE_OF_RADIUS / 4

    private String text; //显示的文字
    private float textSize = 10; //显示文字的大小,默认为10
    private int textColor = Color.WHITE; //显示的文字的颜色,默认为白色

    private float movableBubbleRadius = 15; //可移动气泡大小,默认为15
    private float stillBubbleRadius = 15; //不动气泡的半径

    private int bubbleColor = Color.RED; //气泡颜色, 默认颜色为红色

    private State state = State.DEFAULT; //记录当前气泡状态

    private Paint bubblePaint; //气泡画笔
    private Paint textPaint; //文字画笔

    private PointF stillCircleCenterPoint; //静止的气泡的圆心点
    private PointF movableCircleCenterPoint; //可移动的气泡的圆心点

    private float distance; //移动之后,两个圆圆心之间的距离
    private float maxDistance; //最大的可移动距离,超过则认为为分离状态
    private float movableDistance; //当状态为default的时候,当手指按下的点离圆(非圆心,圆边坐标)的多长的距离可开始移动

    private Path bezierPath; //贝塞尔曲线路径
    private OnBubbleStateChangeListener bubbleStateChangeListener;

    private ValueAnimator movableBubbleReboundAnimator; //可移动气泡的回弹动画
    private ValueAnimator movableBubbleDisappearAnimator; //可移动气泡的消失动画

    private Bitmap[] disappearAnimFrames; //消失动画每一帧的
    private int currentDisappearAnimFrameIndex; //当前消失动画的帧下标

    public FunnyBubbleView(Context context) {
        super(context);
        init();
    }

    public FunnyBubbleView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);

        parseAttr(attrs);
        init();
    }

    public FunnyBubbleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        parseAttr(attrs);
        init();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public FunnyBubbleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        parseAttr(attrs);
        init();
    }

    private void init(){
        bubblePaint = new Paint(Paint.ANTI_ALIAS_FLAG); //抗锯齿
        bubblePaint.setColor(bubbleColor);
        bubblePaint.setStyle(Paint.Style.FILL);

        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setColor(textColor);
        textPaint.setTextSize(textSize);

        bezierPath = new Path();

        //消失动画每一帧图片id
        int frameIds[] = new int[]{R.mipmap.bubble_burst_frame_1, R.mipmap.bubble_burst_frame_2,
                R.mipmap.bubble_burst_frame_3, R.mipmap.bubble_burst_frame_4, R.mipmap.bubble_burst_frame_5};

        disappearAnimFrames = new Bitmap[frameIds.length];

        for (int i = 0; i < frameIds.length; i++){
            disappearAnimFrames[i] = BitmapFactory.decodeResource(getResources(), frameIds[i]);
        }
    }

    /**
     * 视图的大小发生改变后的回调
     * */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

       initSize(w, h);
    }

    /**
     * 初始化view宽高
     * */
    private void initSize(int width, int height){
        //设置两气泡圆心初始坐标
        stillCircleCenterPoint = new PointF(width / 2,height / 2);
        movableCircleCenterPoint = new PointF(width / 2,height / 2);

        state = State.DEFAULT;
    }

    /**
     * 解析属性
     * */
    private void parseAttr(AttributeSet attrs){
        TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.FunnyBubbleView);

        int count = a.getIndexCount();
        for (int i = 0; i < count; i++){
            int attr = a.getIndex(i);
            switch (attr){
                case R.styleable.FunnyBubbleView_radius:
                    movableBubbleRadius = a.getDimension(attr, 15);
                    break;

                case R.styleable.FunnyBubbleView_color:
                    bubbleColor = a.getColor(attr, Color.RED);
                    break;

                case R.styleable.FunnyBubbleView_text:
                    TypedValue typedValue = a.peekValue(attr);
                    if (typedValue != null) {
                        if (typedValue.type == TypedValue.TYPE_REFERENCE) {
                            text = getResources().getString(a.getResourceId(i, 0));
                        } else if (typedValue.type == TypedValue.TYPE_STRING){
                            text = a.getString(attr);
                        }
                    }
                    break;

                case R.styleable.FunnyBubbleView_textColor:
                    textColor = a.getColor(attr, Color.WHITE);
                    break;

                case R.styleable.FunnyBubbleView_textSize:
                    textSize = a.getDimension(attr, 10);
                    break;

                    default: break;

            }
        }
        a.recycle();

        maxDistance = movableBubbleRadius * MAX_DISTANCE_MULTIPLE_OF_RADIUS; //设置最大移动距离为半径的8倍
        movableDistance = maxDistance / MOVABLE_DISTANCE_RATIO_OF_MAX_DISTANCE; //这里设置当手指按下的点在最大可移动距离的四分之一以内可进行气泡移动,否则还是静止状态
        stillBubbleRadius = movableBubbleRadius;
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //若两个气泡相连
        if (state == State.CONNECTING){
            drawConnectingState(canvas);
        }

        //若不为消失状态,绘制移动气泡
        if (state != State.DISAPPEARED
                && state != State.DISAPPEARING){
            drawMovableBubble(canvas);
        }

        //若为消失动画执行中状态,绘制消失动画的帧bitmap
        if (state == State.DISAPPEARING){
            Rect rect = new Rect((int)(movableCircleCenterPoint.x - movableBubbleRadius),
                    (int)(movableCircleCenterPoint.y - movableBubbleRadius),
                    (int)(movableCircleCenterPoint.x + movableBubbleRadius),
                    (int)(movableCircleCenterPoint.y + movableBubbleRadius));

            canvas.drawBitmap(disappearAnimFrames[currentDisappearAnimFrameIndex],null,
                    rect, bubblePaint);
        }
    }

    /**
     * 画可移动的气泡
     * */
    private void drawMovableBubble(Canvas canvas){
        canvas.drawCircle(movableCircleCenterPoint.x, movableCircleCenterPoint.y, movableBubbleRadius, bubblePaint);

        //画text
        text = text == null ? "" : text;
        Rect bounds = new Rect();
        textPaint.getTextBounds(text, 0, text.length(), bounds); //计算得到要显示的文字的宽高
        canvas.drawText(text, movableCircleCenterPoint.x - bounds.width() / 2, movableCircleCenterPoint.y + bounds.height() / 2, textPaint); //文字居中
    }

    /**
     * 绘制两个气泡相连
     * */
    private void drawConnectingState(Canvas canvas){
        //画静止气泡,静止气泡会根据移动的距离增加而变小
        canvas.drawCircle(stillCircleCenterPoint.x, stillCircleCenterPoint.y, stillBubbleRadius, bubblePaint);

        //先计算两个圆心连接直线中心坐标
        float anchorX = (movableCircleCenterPoint.x + stillCircleCenterPoint.x) / 2;
        float anchorY = (movableCircleCenterPoint.y + stillCircleCenterPoint.y) / 2;

        //计算圆心直接连线与X轴的夹角的角度
        //分别得到对边/斜边,邻边/斜边的值
        float sin = (movableCircleCenterPoint.y - stillCircleCenterPoint.y) / distance;
        float cos = (movableCircleCenterPoint.x - stillCircleCenterPoint.x) / distance;

        //静止气泡的开始点和结束点坐标
        float stillBubbleStartX = stillCircleCenterPoint.x - sin * stillBubbleRadius;
        float stillBubbleStartY = stillCircleCenterPoint.y + cos * stillBubbleRadius;
        float stillBubbleEndX = stillCircleCenterPoint.x + sin * stillBubbleRadius;
        float stillBubbleEndY = stillCircleCenterPoint.y - cos * stillBubbleRadius;


        //可移动气泡的开始点和结束点坐标
        float movableBubbleStartX = movableCircleCenterPoint.x - movableBubbleRadius * sin;
        float movableBubbleStartY = movableCircleCenterPoint.y + movableBubbleRadius * cos;
        float movableBubbleEndX = movableCircleCenterPoint.x + movableBubbleRadius * sin;
        float movableBubbleEndY = movableCircleCenterPoint.y - movableBubbleRadius * cos;

        bezierPath.reset();
        bezierPath.moveTo(stillBubbleStartX, stillBubbleStartY); //先移动至静止气泡的起始点
        bezierPath.quadTo(anchorX, anchorY, movableBubbleStartX, movableBubbleStartY); //绘制贝塞尔曲线
        bezierPath.lineTo(movableBubbleEndX, movableBubbleEndY);//再移动至可移动气泡的结束点
        bezierPath.quadTo(anchorX, anchorY, stillBubbleEndX, stillBubbleEndY); //再次绘制贝塞尔曲线
        bezierPath.close(); //闭合path

        canvas.drawPath(bezierPath, bubblePaint); //绘制path
    }

    /**
     * 要显示的内容
     *
     * @param text 内容
     * */
    public void setText(String text) {
        this.text = text;

        state = State.DEFAULT;
        invalidate();
    }

    /**
     * touch down事件
     * */
    private void onTouchDown(MotionEvent event){
        if (state == State.DISAPPEARED ||
                state == State.DISAPPEARING){ //若气泡消失,则不处理
            return;
        }

        distance = (float) Math.hypot(event.getX() - stillCircleCenterPoint.x,
                event.getY() - stillCircleCenterPoint.y); //计算手指按下的点离静止圆圆心的距离

        if (distance < (movableBubbleRadius + movableDistance)){ //气泡是否可移动
            state = State.CONNECTING; //气泡开始连接
        }else{
            state = State.DEFAULT;
        }

        notifyStateChange();
    }

    /**
     * touch up事件
     * */
    private void onTouchUp(MotionEvent event) {
        if(state == State.CONNECTING){
            startMovableBubbleReboundAnim();
        }else if(state == State.APART){
            if(distance < maxDistance){ //若不超过最长可移动范围,则执行回弹动画
                startMovableBubbleReboundAnim();
            }else{ //否则执行消失动画
                startBubbleDisappearAnim();
            }
        }
    }

    /**
     * touch move事件
     * */
    private void onTouchMove(MotionEvent event) {
        if (state == State.DEFAULT){
            return;
        }

        //将可移动的气泡的圆心移至touch down的点
        movableCircleCenterPoint.x = event.getX();
        movableCircleCenterPoint.y = event.getY();

        distance = (float) Math.hypot(event.getX() - stillCircleCenterPoint.x,
                event.getY() - stillCircleCenterPoint.y); //计算手指按下的点离静止圆圆心的距离,即计算两点之间的距离

        if(state == State.CONNECTING){
            if(distance < (maxDistance - movableDistance)){
                //这里伴随着两个气泡之间移动距离的增加,静止的气泡大小会按比例变小,这里我们为了不让静止气泡过小,设置超过maxDistance - movableDistance的时候,则设置为分离状态
                stillBubbleRadius = movableBubbleRadius - distance / MAX_DISTANCE_MULTIPLE_OF_RADIUS;
            }else{
                state = State.APART;
                notifyStateChange();
            }
        }

        //刷新
        invalidate();
    }

    /**
     * 开始可移动气泡回弹动画,恢复到DEFAULT状态
     * */
    private void startMovableBubbleReboundAnim(){
        //可移动气泡动画回弹效果
        movableBubbleReboundAnimator = ValueAnimator.ofObject(new PointFEvaluator(),
                new PointF(movableCircleCenterPoint.x, movableCircleCenterPoint.y),
                new PointF(stillCircleCenterPoint.x, stillCircleCenterPoint.y));

        movableBubbleReboundAnimator.setDuration(150);
        movableBubbleReboundAnimator.setInterpolator(new AnticipateOvershootInterpolator());

        movableBubbleReboundAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                movableCircleCenterPoint = (PointF) animation.getAnimatedValue();
                invalidate();
            }
        });

        movableBubbleReboundAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                state = State.DEFAULT;
                notifyStateChange();
            }
        });

        movableBubbleReboundAnimator.start();
    }

    /**
     * 开始气泡消失效果
     * */
    private void startBubbleDisappearAnim(){
        //做一个int型属性动画,从0~mBurstDrawablesArray.length结束
        movableBubbleDisappearAnimator = ValueAnimator.ofInt(0, disappearAnimFrames.length);
        movableBubbleDisappearAnimator.setInterpolator(new LinearInterpolator());
        movableBubbleDisappearAnimator.setDuration(500);
        movableBubbleDisappearAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //设置当前绘制的爆炸图片index
                currentDisappearAnimFrameIndex = (int) animation.getAnimatedValue();
                invalidate();
            }
        });
        movableBubbleDisappearAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                state = State.DISAPPEARED;
                notifyStateChange();
            }

            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                state = State.DISAPPEARING;
                notifyStateChange();
            }
        });
        movableBubbleDisappearAnimator.start();
    }

    /**
     * 通知状态改变
     * */
    private void notifyStateChange(){
        if (bubbleStateChangeListener != null){
            bubbleStateChangeListener.onStateChange(state);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                onTouchDown(event);
                break;

            case MotionEvent.ACTION_MOVE:
                onTouchMove(event);
                break;

            case MotionEvent.ACTION_UP:
                onTouchUp(event);
                break;

                default:
                    break;
        }

        return true;
    }

    /**
     * 恢复到起始状态
     * */
    public void reset(){
        if (movableBubbleReboundAnimator != null) {
            movableBubbleReboundAnimator.cancel();
        }

        if (movableBubbleDisappearAnimator != null){
            movableBubbleDisappearAnimator.cancel();
        }

        initSize(getWidth(), getHeight()); //重新设置圆心坐标
        state = State.DEFAULT; //状态重置
        notifyStateChange(); //通知变化
        invalidate(); //重新绘制
    }

    public void setBubbleStateChangeListener(OnBubbleStateChangeListener bubbleStateChangeListener) {
        this.bubbleStateChangeListener = bubbleStateChangeListener;
    }

    /**
     * 自定义状态监听器
     * */
    public interface OnBubbleStateChangeListener{
        /**
         * 状态改变
         *
         * @param state 当前状态
         * */
        void onStateChange(State state);
    }
}

效果图:
Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

水波浪充电效果

根据贝塞尔曲线,手写了一个波浪充电效果,已经放到github上面了:
https://github.com/samlss/ChargingView

在根目录的build.gradle添加这一句代码:

allprojects {
    repositories {
        //...
        maven { url 'https://jitpack.io' }
    }
}

在app目录下的build.gradle添加依赖使用:

dependencies {
    implementation 'com.github.samlss:ChargingView:1.0'
}

布局中使用:

<com.iigo.library.ChargingView
            android:id="@+id/cv2"
            android:layout_marginTop="10dp"
            android:layout_width="100dp"
            android:layout_height="200dp"
            app:progress="50"
            app:progressTextColor="@android:color/black"
            app:chargingColor="@android:color/holo_red_light"
            app:bg_color="#eeeeee"
            app:progressTextSize="20dp" />


代码中使用:

  chargingView.setProgress(95);
  chargingView.setBgColor(Color.parseColor("#aaaaaa"));
  chargingView.setChargingColor(Color.YELLOW);
  chargingView.setTextColor(Color.RED);
  chargingView.setTextSize(25);


属性说明:

属性 说明
bg_color 背景颜色
chargingColor 充电中颜色
progress 当前进度0-100
progressTextSize 显示进度text大小
progressTextColor 显示进度text颜色

Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果

Demo地址:https://github.com/samlss/Bezier
个人github总结:https://github.com/samlss/AsAndroidDevelop

上一篇: 贝塞尔曲线

下一篇: DIY心形流水灯