记一次意外的自定义控件

作者: Razerdp | 来源:发表于2016-03-02 23:19 被阅读832次

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

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

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


    效果图

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

    起因

    事情的起源是这样滴,因为某种需求,咱们需要撸一个这样子的控件(为了不泄露设计图,咱们就拿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;
        }
    }
    

    相关文章

      网友评论

      • 捡淑:66666
      • 天之大任:朋友在吗
      • ef3366804c46:楼主的文章风趣幽默 学习了
      • CoderBigBear:那个边,多半是你画路径,沟通了起点和终点。把画阴影块和线区分开path。线就不要沟通起始点了。大概是这样的。
        Razerdp:@Samson_wang 是的,现在就是这样,两个是不同的线,不过由于路径动画采用的依然是原始的路径,所以会一直画下去。解决方法其实很简单,当动画得到的value等于最后一个点的x position,就取消动画就可以了。
      • 爱孔孟:好强大
      • 66aa897454a2:66666666啊
      • YoKey:棒棒哒
      • Zack_zhou:问一下那个查库有多少方法的是什么网站?
        Zack_zhou:@羽翼君 是手机上看的,当时没有注意到有超链接,问了一个好愚蠢的问题
        Razerdp:@Zack_zhou http://www.methodscount.com/

        我记得文中我有超链接的
      • 键盘男:不错不错,当时我绘制贝塞尔曲线,也是拿网上例子的,也没那么深入。楼主辛苦了
        Razerdp:@苦逼键盘男kkmike999 我也没深入……起码那些公式什么的根本没看(完全不想看……),不过因为会一些图像后期处理,胡乱玩玩钢笔工具忽然发现的一个规律,嘿嘿,所以还挺意外的
      • 陆地蛟龙:这篇文章 费心了,要写几个小时。
        Razerdp:@胡髭蛤蟆 ^ω^

      本文标题:记一次意外的自定义控件

      本文链接:https://www.haomeiwen.com/subject/rzvnkttx.html