记一次意外的自定义控件

有时候,意外也许就会造成一个不经意间的成功。

【注意:本文章前两节尽是吐槽,要看代码,实现方案什么的,请直接看第三节】
【注意:本文章前两节尽是吐槽,要看代码,实现方案什么的,请直接看第三节】
【注意:本文章前两节尽是吐槽,要看代码,实现方案什么的,请直接看第三节】
重要的话要说三遍。。。

咳咳,,,咱们不是专业写手,就不要那么装文艺了,还是逗比点好。
不如咱们先上个图?


效果图

咳咳,请忽略我竖屏录制了啦。。。。还有,请忽略为啥那条线会在屏幕边边走,在下不拘束它的自由←_←

起因

事情的起源是这样滴,因为某种需求,咱们需要撸一个这样子的控件(为了不泄露设计图,咱们就拿MPAndroidChart的图展示吧,反正需求都一样):

伪设计图

拿到设计图,第一想法,这有多难,直接上MP库呗,于是把库放到MethodsCount一查,哭了。。。2K多个方法欸,2K欸!!!!2K!!!!

方法统计

遂放弃,,,还是自己开干吧

看到曲线什么的,第一时间**贝塞尔曲线**走起~ 于是,最为一个面向搜索引擎编程的程序员,当然谷歌一下贝塞尔。。。

随便搜搜,于是就看到CSDN的一篇文章文章点我

啊~好细致,好赞啊!!!可惜在下没法短时间内理解啊TAT。然而,按照我平时的经验,还是撸个初步的东西出来吧。。。

OMG

OMG....这神马啊,这尖尖,都快能戳死人了好吗。。。。
于是,选择战略性撤退,休息一晚再开干。

意外

第二天,毫无疑问的继续一脸蒙逼。。。
这时候,一位老朋友叫我帮他抠个图,是的,你没看错,抠图。。。。如果有看过我的一起撸个朋友圈系列文章的人,或许会知道,在下也会AE这个视频后期软件。。。

抠就抠吧。。。。但!!!
意外就这么来了。。。。抠图的时候,为了边缘平滑,我经常调节锚点,使曲线更加的平滑,然后居然让我发现了一个规律0.0,大致原理如下吧:

AE

如图,如果多看几遍,也许你会发现,当两个控制点的x位置在前后两个坐标内,而y分别与前后两个坐标平齐的时候,转折点的衔接最为平滑,否则妥妥的出现尖尖(嗯。。。我还特地用鼠标绕了几圈标出尖尖位置)。

妈蛋,得来毫不费功夫啊。。。。真的想抱着我朋友亲几口,可惜在下不搞基- -

实现

既然找到了突破口,那妥妥的开干啊。

于是兴冲冲的继承View,开始我们的伟业:

public class TestView extends View {
    // 最大值
    private final float maxValue = 100f;
    // 测试数据
    private float[] testDatas = { 55f, 38f, 50f, 44f, 31f, 22f, 9f, 19f, 50f, 78f, 62f, 51f, 45f, 66f, 79f, 50f, 33f,
            24f, 26f, 58f };
    //private float[] testDatas = { 60f, 55f, 57f, 50f ,56f,70f};
    //private float[] testDatas = { 60f, 55f};

    // 点记录
    private List<Point> datas;

    private final int num = 12;

    // 路径
    private Path clicPath;
    // 渐变填充
    private Paint mPaint;
    
    // 辅助性画笔
    private Paint controllPaintA;
    private Paint controllPaintB;
    private Path linePath;

    
    private PathMeasure mPathMeasure;
    private float[] mCurrentPosition = new float[2];
    private float[] mPrePosition = new float[2];

    LinearGradient mGradient;

    int width;
    int height;
    int offSet;

...构造器初始化以上的东西

我们定义了一个最大值,和一组测试数据。这个最大值的作用是用来计算当前数据在屏幕的y位置,比如这样:最大值100,我们的数值15,但我们的屏幕是720*1280,那么当然不可以只画15像素了,这怎么看得到嘛,我们的y位置判定为:

屏幕高度*(1-(15/100))

为什么要用1减去百分比,因为原点不在左下角而在左上角,所以我们需要减掉。

接下来到measure初始化我们的点。

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
        height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
        offSet = width / testDatas.length;
        if (datas.size() == 0) {
            for (int i = 0; i < testDatas.length; i++) {
                float ratio = testDatas[i] / maxValue;
                Point point;
                if (i == 0) {
                    point = new Point(0, (int) (height * (1 - ratio)));
                }
                else if (i == testDatas.length - 1) {
                    point = new Point(width, (int) (height * (1 - ratio)));
                }
                else {
                    point = new Point(i * offSet, (int) (height * (1 - ratio)));
                }
                datas.add(point);
            }
        }
        if (mGradient == null) {
            mGradient = new LinearGradient(getMeasuredWidth() >> 1, getMeasuredHeight() >> 1, getMeasuredWidth() >> 1,
                    getMeasuredHeight(), Color.parseColor("#e0cab3"), Color.parseColor("#ffffff"),
                    Shader.TileMode.CLAMP);
            mPaint.setShader(mGradient);
        }
    }

其中我们的offSet是偏移量,其作用是使点在屏幕上的x位置是均分的,然后初始化一个线性渐变。

这时候我们的点是这样的(为了更方便查看,我们设定为横屏并给上线条):

点和点之间的x偏移都是一致的(最后一个除外)

然后我们在onDraw开始绘制():

 @Override
    protected void onDraw(Canvas canvas) {
        clicPath.reset();
        super.onDraw(canvas);
        //clicPath.moveTo(datas.get(0).x, datas.get(0).y);
        for (int i = 0; i < datas.size() - 1; i++) {
            Point startPoint = datas.get(i);
            Point endPoint = datas.get(i + 1);
            if (i == 0) clicPath.moveTo(startPoint.x, startPoint.y);

            int controllA_X = (startPoint.x + endPoint.x) >>1;
            int controllA_Y = startPoint.y;
            int controllB_X = (startPoint.x + endPoint.x) >>1;
            int controllB_Y = endPoint.y;
            clicPath.cubicTo(controllA_X, controllA_Y, controllB_X, controllB_Y, endPoint.x, endPoint.y);

            // 控制点展示
            canvas.drawCircle(controllA_X,controllA_Y,5,controllPaintA);
            canvas.drawCircle(controllB_X,controllB_Y,5,controllPaintB);


            canvas.drawCircle(startPoint.x,startPoint.y,5,mPaint);


            //控制点展示
            canvas.drawLine(startPoint.x,startPoint.y,controllA_X,controllA_Y,mPaint);
            canvas.drawLine(endPoint.x,endPoint.y,controllB_X,controllB_Y,mPaint);

        }
        clicPath.lineTo(datas.get(datas.size() - 1).x, height);
        clicPath.lineTo(datas.get(0).x, height);
        clicPath.lineTo(datas.get(0).x, datas.get(0).y);
        canvas.drawPath(clicPath, mPaint);
    }

这里解析一下:
当i==0,也就是画第一个点的时候,我们需要把画笔移到我们第一个点的位置,否则永远都会从0,0开始,以后就不需要移动了,因为画完一条线后,画笔位置会停留在最后一个点。

我们可以看到两个控制点的坐标,跟我们上面AE展示出来的是一样的,x位置都是取两个点的中间,y则是分别跟两边平齐,这样的曲线最为圆滑

clicPath.cubicTo这个方法,前面4个参数分别代表着控制点1的xy,控制点2的xy,最后一个参数则是结束点的xy,在下一次循环到来之时,最后一个参数则会作为下一次绘制的起点。

最后别忘了在循环外面将path封闭起来,我们不可以直接用path.close(),因为close方法是最后一个点与第一个点直接连一条直线的,但我们需要填充曲线下方。

为了方便展示,我们添加了参考点以及将线条设置为stroke,先不填充:

预览图

可以看到,我们的控制点都很好的分布在两点之间,曲线看起来十分平滑。

为了更清晰,我们将测试数据减少一点:

private float[] testDatas = { 60f, 30f, 57f, 41f ,88f,70f};
预览图2

现在看起来更加的清晰,然后我们填充一下并取消掉辅助线条和辅助点。

预览图3

现在初步达到我们的效果了。。

然而,程序员的冤家产品却说:哎,这太单调了,给个动画呗。。。。

妈蛋!!!!!

不过骂完还是得干啊-T-

于是这次我们需要借助PathMeasure这个类

这个类通常用于将某个path转换为一个具体的position,更多情况下是用作路径动画。

还记得我们之前定义的变量里面有些什么吗:

    private PathMeasure mPathMeasure;
    private float[] mCurrentPosition = new float[2];
    private float[] mPrePosition = new float[2];

根据命名,也很清楚是干啥的。

接下来继续开工:

首先定义一个公用方法给外部调用:

public void startAnima(long duration) {}

我们通过这个方法来绘制线条

然后我们利用ValueAnimator来动态获取我们path的坐标

 public void startAnima(long duration) {
        if (mPathMeasure == null) mPathMeasure = new PathMeasure(clicPath, true);
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
        valueAnimator.setDuration(duration);
        // 减速插值器
        valueAnimator.setInterpolator(new DecelerateInterpolator());
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (Float) animation.getAnimatedValue();
                // 获取当前点坐标封装到mCurrentPosition
                mPathMeasure.getPosTan(value, mCurrentPosition, null);
                invalidate();
                if (value == mPathMeasure.getLength()) animaFirst = true;
            }
        });
        valueAnimator.start();
    }

为了防止onDraw里面多次绘制,我们定义一个animaFirst。

然后补充我们的onDraw方法:

  @Override
    protected void onDraw(Canvas canvas) {
    ...
        if (animaFirst) {
            linePath.moveTo(datas.get(0).x, datas.get(0).y);
            mPrePosition[0] = datas.get(0).x;
            mPrePosition[1] = datas.get(0).y;
            animaFirst = false;
        }
        else {
            int controllA_X = (int) ((mPrePosition[0] + mCurrentPosition[0]) /2);
            int controllA_Y = (int) mPrePosition[1];
            int controllB_X = (int) ((mPrePosition[0] + mCurrentPosition[0]) /2);
            int controllB_Y = (int) mCurrentPosition[1];
            linePath.cubicTo(controllA_X, controllA_Y, controllB_X, controllB_Y, mCurrentPosition[0],
                    mCurrentPosition[1]);
            mPrePosition[0] = mCurrentPosition[0];
            mPrePosition[1] = mCurrentPosition[1];
        }
        canvas.drawPath(linePath, controllPaintA);
    }

如果动画刚启动,我们就把点移到第一个点的位置,同时记录
如果动画已经启动了,我们就重复前面的步骤画出贝塞尔,当然,你也可以直接lineTo,然后将当前点付给前一个点。

最后,我们在onDetachedFromWindow清掉各种信息,毕竟那啥,内存还是挺珍贵的对吧-V-

   @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        
        datas.clear();
        clicPath=null;
        controllPaintA=null;
        controllPaintB=null;
        mPathMeasure=null;
        
    }

最终效果图(未修复到屏幕边边继续画的问题。。。,以及貌似有些地方有点偏差):

preview

【附】所有代码(可以直接copy使用,因为是测试demo,所以并没有封装什么的,同时measure那里也没有指定wrap_content时的大小,大家可以自行封装或修复或扩展哈哈-V-):

/**
 * Created by 大灯泡 on 2016/2/29.
 */
public class TestView extends View {
    // 最大值
    private final float maxValue = 100f;
    // 测试数据
    //private float[] testDatas = { 55f, 38f, 50f, 44f, 31f, 22f, 9f, 19f, 50f, 78f, 62f, 51f, 45f, 66f, 79f, 50f, 33f,
    //        24f, 26f, 58f };
    private float[] testDatas = { 60f, 30f, 57f, 41f, 88f, 70f };
    //private float[] testDatas = { 60f, 55f};

    // 点记录
    private List<Point> datas;
    // 路径
    private Path clicPath;
    // 渐变填充
    private Paint mPaint;
    // 辅助性画笔
    private Paint controllPaintA;
    private Paint controllPaintB;
    private Path linePath;

    private PathMeasure mPathMeasure;
    private float[] mCurrentPosition = new float[2];
    private float[] mPrePosition = new float[2];
    LinearGradient mGradient;
    int width;
    int height;
    int offSet;

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

    public TestView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TestView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        clicPath = new Path();
        linePath = new Path();
        datas = new ArrayList<>();
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //mPaint.setStyle(Paint.Style.STROKE);
        controllPaintA = new Paint(Paint.ANTI_ALIAS_FLAG);
        controllPaintA.setStyle(Paint.Style.STROKE);
        controllPaintA.setStrokeWidth(5);
        controllPaintA.setColor(0xffff0000);

        controllPaintB = new Paint(Paint.ANTI_ALIAS_FLAG);
        controllPaintB.setStyle(Paint.Style.STROKE);
        controllPaintB.setColor(0xff00ff00);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
        height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
        offSet = width / testDatas.length;
        if (datas.size() == 0) {
            for (int i = 0; i < testDatas.length; i++) {
                float ratio = testDatas[i] / maxValue;
                Point point;
                if (i == 0) {
                    point = new Point(0, (int) (height * (1 - ratio)));
                }
                else if (i == testDatas.length - 1) {
                    point = new Point(width, (int) (height * (1 - ratio)));
                }
                else {
                    point = new Point(i * offSet, (int) (height * (1 - ratio)));
                }
                datas.add(point);
            }
        }
        if (mGradient == null) {
            mGradient = new LinearGradient(getMeasuredWidth() >> 1, getMeasuredHeight() >> 1, getMeasuredWidth() >> 1,
                    getMeasuredHeight(), Color.parseColor("#e0cab3"), Color.parseColor("#ffffff"),
                    Shader.TileMode.CLAMP);
            mPaint.setShader(mGradient);
        }
    }

    private boolean animaFirst = true;
    @Override
    protected void onDraw(Canvas canvas) {
        clicPath.reset();
        super.onDraw(canvas);
        for (int i = 0; i < datas.size() - 1; i++) {
            Point startPoint = datas.get(i);
            Point endPoint = datas.get(i + 1);
            if (i == 0) clicPath.moveTo(startPoint.x, startPoint.y);

            int controllA_X = (startPoint.x + endPoint.x) >> 1;
            int controllA_Y = startPoint.y;
            int controllB_X = (startPoint.x + endPoint.x) >> 1;
            int controllB_Y = endPoint.y;
            clicPath.cubicTo(controllA_X, controllA_Y, controllB_X, controllB_Y, endPoint.x, endPoint.y);
            /**辅助点和线**/
            //canvas.drawCircle(controllA_X,controllA_Y,5,controllPaintA);
            //canvas.drawCircle(controllB_X,controllB_Y,5,controllPaintB);

            //canvas.drawCircle(startPoint.x,startPoint.y,5,mPaint);

            //canvas.drawLine(startPoint.x,startPoint.y,controllA_X,controllA_Y,mPaint);
            //canvas.drawLine(endPoint.x,endPoint.y,controllB_X,controllB_Y,mPaint);

        }
        clicPath.lineTo(datas.get(datas.size() - 1).x, height);
        clicPath.lineTo(datas.get(0).x, height);
        clicPath.lineTo(datas.get(0).x, datas.get(0).y);
        canvas.drawPath(clicPath, mPaint);

        if (animaFirst) {
            linePath.moveTo(datas.get(0).x, datas.get(0).y);
            mPrePosition[0] = datas.get(0).x;
            mPrePosition[1] = datas.get(0).y;
            animaFirst = false;
        }
        else {
            int controllA_X = (int) ((mPrePosition[0] + mCurrentPosition[0]) / 2);
            int controllA_Y = (int) mPrePosition[1];
            int controllB_X = (int) ((mPrePosition[0] + mCurrentPosition[0]) / 2);
            int controllB_Y = (int) mCurrentPosition[1];
            linePath.cubicTo(controllA_X, controllA_Y, controllB_X, controllB_Y, mCurrentPosition[0],
                    mCurrentPosition[1]);
            mPrePosition[0] = mCurrentPosition[0];
            mPrePosition[1] = mCurrentPosition[1];
        }
        canvas.drawPath(linePath, controllPaintA);
    }

    public void startAnima(long duration) {
        if (mPathMeasure == null) mPathMeasure = new PathMeasure(clicPath, true);
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
        valueAnimator.setDuration(duration);
        // 减速插值器
        valueAnimator.setInterpolator(new DecelerateInterpolator());
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (Float) animation.getAnimatedValue();
                // 获取当前点坐标封装到mCurrentPosition
                mPathMeasure.getPosTan(value, mCurrentPosition, null);
                Log.d("curX",""+mCurrentPosition[0]);
                invalidate();
                if (value == mPathMeasure.getLength())
                    animaFirst = true;
            }
        });
        valueAnimator.start();
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();

        datas.clear();
        clicPath = null;
        controllPaintA = null;
        controllPaintB = null;
        mPathMeasure = null;
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容