记一次意外的自定义控件
有时候,意外也许就会造成一个不经意间的成功。
【注意:本文章前两节尽是吐槽,要看代码,实现方案什么的,请直接看第三节】
【注意:本文章前两节尽是吐槽,要看代码,实现方案什么的,请直接看第三节】
【注意:本文章前两节尽是吐槽,要看代码,实现方案什么的,请直接看第三节】
重要的话要说三遍。。。
咳咳,,,咱们不是专业写手,就不要那么装文艺了,还是逗比点好。
不如咱们先上个图?
效果图
咳咳,请忽略我竖屏录制了啦。。。。还有,请忽略为啥那条线会在屏幕边边走,在下不拘束它的自由←_←
起因
事情的起源是这样滴,因为某种需求,咱们需要撸一个这样子的控件(为了不泄露设计图,咱们就拿MPAndroidChart的图展示吧,反正需求都一样):
伪设计图拿到设计图,第一想法,这有多难,直接上MP库呗,于是把库放到MethodsCount一查,哭了。。。2K多个方法欸,2K欸!!!!2K!!!!
方法统计遂放弃,,,还是自己开干吧
看到曲线什么的,第一时间**贝塞尔曲线**
走起~ 于是,最为一个面向搜索引擎编程的程序员,当然谷歌一下贝塞尔。。。
随便搜搜,于是就看到CSDN的一篇文章文章点我。
啊~好细致,好赞啊!!!可惜在下没法短时间内理解啊TAT。然而,按照我平时的经验,还是撸个初步的东西出来吧。。。
OMGOMG....这神马啊,这尖尖,都快能戳死人了好吗。。。。
于是,选择战略性撤退,休息一晚再开干。
意外
第二天,毫无疑问的继续一脸蒙逼。。。
这时候,一位老朋友叫我帮他抠个图,是的,你没看错,抠图。。。。如果有看过我的一起撸个朋友圈系列文章的人,或许会知道,在下也会AE这个视频后期软件。。。
抠就抠吧。。。。但!!!
意外就这么来了。。。。抠图的时候,为了边缘平滑,我经常调节锚点,使曲线更加的平滑,然后居然让我发现了一个规律0.0,大致原理如下吧:
如图,如果多看几遍,也许你会发现,当两个控制点的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;
}
}