Android自定义View——贝赛尔曲线

个人博客:haichenyi.com。感谢关注

  本文针对有一定自定义View的童鞋,最好对贝赛尔曲线有辣么一丢丢了解,不了解也没关系。花5分钟看一下 GcsSloop安卓自定义View进阶-Path之贝塞尔曲线

本文的最终效果图:


最终效果图.gif

思路

  1. 首先他是一个只有上半部分的正弦形状的水波纹,很规则。
  2. 其次,他这个正弦图左右在移动。
  3. 然后,就是它这个自定义View,上下也在移动,是慢慢增加的
  4. 最后,优化点:一开始刚出来的时候,它那个水波纹的角度,更达到一定角度后,最后面,快要完成的时候的角度是不一样的。

第一步:画正弦形状的水波纹

  有一定自定义View基础的童鞋都知道,一阶贝赛尔画直线,这里的正弦图形是用二阶贝赛尔曲线。至于三阶,四阶,五阶用的都比较少。

  我们这里知道了,这是用的二阶贝赛尔曲线,辣么,方法呢?

//不带r的方法是基于原点的坐标系(偏移量), rXxx方法是基于当前点坐标系(偏移量)
mPath.rQuadTo(dx1, dy1, dx2, dy2);
//mPath.quadTo(dx1, dy1, dx2, dy2);

  Path调用该方法,这里就是传的两个点,也就是四个值,参数的含义:第一个点是控制点,第二个点是终点。前面还有一个起点,通过

mPath.moveTo(x,y);

  这个方法是确定起点。不懂的童鞋,看一下文章开头推荐的文章。我们效果的是一排波浪线,我们上面这个方法只是一个。举个例子:

//构造方法里面初始化
private void initView() {
    path = new Path();
    paint = new Paint();
    paint1 = new Paint();
    paint.setColor(Color.GREEN);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(10);
    paint1.set(paint);
    paint1.setColor(Color.RED);
  }
//onDraw里面去画出来
    @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    path.moveTo(0,300);
    path.quadTo(150, 150, 300, 300);
    path.quadTo(450, 450, 600, 300);
    canvas.drawPath(path, paint);

    canvas.drawCircle(0,300,5,paint1);
    canvas.drawCircle(150,150,5,paint1);
    canvas.drawCircle(300,300,5,paint1);
    canvas.drawCircle(450,450,5,paint1);
    canvas.drawCircle(600,300,5,paint1);
  }

  上面就是简单的初始化paint,和path,然后画出path,另外,我还画出了5个点,帮助理解。跑出来的效果图如下:

一个水波纹效果图.png

  转换成对应的坐标系,手画的,有点丑,知道是这个意思就行了。如下:

一个水波纹放入坐标系中.png

画一个正弦图的思路:

  1. 首先,把path移动到起点,对应的也就是moveTo(0,300)

  2. 然后,确定终点,也就是我们前面说的quadTo()方法的第二个点(300,300)

  3. 最后,我们确定控制点,也就是我们前面说的quadTo()方法的第一个点。 辣么,这个控制点是怎么确定的呢?问题就在这里。敲黑板 因为我们画的是一个规则的正弦图,所以,控制点的x坐标肯定是终点x坐标300的一半,也就是150。再就是他的y坐标,其实y坐标是随便定义的。y坐标只是约束这个正弦图形的坡度,对坡度。你把y坐标定义的离终点的y坐标远一点,他的坡度就大一点。离他近一点,坡度就小一点。你如果定义控制点是(150,100),他相对于控制点是(150,150)的坡度就会大一点。因为100距300相差200,150距300相差150。200大于150。对,就是这样。辣么,怎么控制是上半部分的正弦图还是下半部分的正弦图呢? |y控|>|y终|,上半部分;相反,则是下半部分。

  对了,这里我需要说明的是,上面我们调用了两次quadTo()方法,第二次调用的起点,就是第一次的终点。

  上面效果是调用quadTo()方法,我们再来说一说rQuadTo()方法。上面的注释里面,我们也标明了两者的区别。 辣么,什么叫相对于原点的坐标系?什么叫相对于当前点的坐标系呢? 我们知道android的坐标系原点是左上角,你可以这样理解,第一种,不带r的方法quadTo(),他的坐标原点(0,0)点始终在左上角,第二种带r的方法rQuadTo(),我们第一次移动到起点(0,300)的时候,这个时候的原点就是(0,300),所以说此时的终点应该是(300,0),然后确定我们的控制点(150,-150)。辣么,我们调用第二次的时候,此时的终点就是(300,0),这个时候的终点就是(300,0),在确定此时的控制点(150,150)。两次的终点都是(300,0),但是,意义是不一样的。有点绕,但是你理解了相对于原点坐标系,和相对于当前点的坐标系,就很简单了。理解一下,思考5分钟。辣么,上面用带r的怎么写呢?

path.moveTo(0,300);
//    path.quadTo(150, 150, 300, 300);
//    path.quadTo(450, 450, 600, 300);
    path.rQuadTo(150,-150,300,0);
    path.rQuadTo(150,150,300,0);
    canvas.drawPath(path, paint);

  至此,怎么换一个正弦图,以及,两个方法的区别,已经讲完了,我觉得已经讲的非常清楚了。感觉,没有谁比我讲的还要清楚了。手把手教学。我们这个效果,画一个,肯定不行。要画满一个屏幕。怎么画呢?

  找规律,一个正弦图,我们上面都是围绕这三个点,起点,控制点,终点。要想规则,控制点的x坐标是终点x坐标的一半。再就是,要画满一个屏幕,要在屏幕内部,所以,终点x坐标要小于屏幕宽度。综上所述。

  1. 三个点:起点,终点,控制点

  2. 控制点的x坐标是终点x坐标的一半

  3. 终点x坐标要小于屏幕宽度

我们就开始写代码了:

  private int startY = 300;//定义起始点的y坐标

  private int endX = 300;//定义终点的x坐标

  private int controlY = 150;//定义控制点的y坐标
  
  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    path.moveTo(0, 300);
    path.moveTo(0, startY);
//    path.quadTo(150, 150, 300, 300);
//    path.quadTo(450, 450, 600, 300);
//这里的for循环为什么每次要加2倍的终点x坐标呢?
    //你想一想,我们一次for循环,画的图的终点x坐标在哪?
    for (int i = 0; i < getWidth(); i += 2*endX) {
//      path.rQuadTo(150, -150, 300, 0);
//      path.rQuadTo(150, 150, 300, 0);
      path.rQuadTo(endX/2, -controlY, endX, 0);
      path.rQuadTo(endX/2, controlY, endX, 0);
    }
    canvas.drawPath(path, paint);
  }

效果图,如下:

整个屏幕的水波纹效果图.png

  好,到这里,第一步完成了,满屏的水波纹出来了。

第二步,正弦图左右在移动

  想一想,这个动画,想一想,想一想,像不像水平位移动画?像不像?越想越像。辣么,我们就去验证一下。写一个动画,这种,明显就是属性动画。既然是左右移动,辣么就肯定是改变x轴的坐标值,改变谁的呢?肯定是起点的啊,只有改变起点的x左边的值,水波纹才会有动的效果

public void startAnimation(){
    ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
    animator.setDuration(1000);
    animator.setRepeatCount(ValueAnimator.INFINITE);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        Float animatedValue = (Float) animation.getAnimatedValue();
        currentStartX = (int) (endX * animatedValue);
        postInvalidate();
      }
    });
    animator.start();
  }
  
   @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    path.reset();//不加会有重影,不是我们想要的
//    path.moveTo(0, 300);
//    path.moveTo(0, startY);
    path.moveTo(currentStartX, startY);
      ...//其他的不变
  }

效果图如下:

移动有空白.gif

  尼玛,什么鬼?动是动起来了,为啥左边还有一段空白?不要急,想一想为什么?我们之前是从Y轴开始画的,我们这个动画是从左向又移动一个endx的值,所以,我们设置起点的时候,也向左偏移一个endx的值不就好了么?我们再试一试

@Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    path.reset();
//    path.moveTo(0, 300);
//    path.moveTo(0, startY);
    path.moveTo(-endX + currentStartX, startY);
//    path.quadTo(150, 150, 300, 300);
//    path.quadTo(450, 450, 600, 300);
    //这里的for循环为什么每次要加2倍的终点x坐标呢?
    //你想一想,我们一次for循环,画的图的终点x坐标在哪?
    for (int i = -endX; i < getWidth() + endX; i += 2 * endX) {
//      path.rQuadTo(150, -150, 300, 0);
//      path.rQuadTo(150, 150, 300, 0);
      path.rQuadTo(endX / 2, -controlY, endX, 0);
      path.rQuadTo(endX / 2, controlY, endX, 0);
    }
    canvas.drawPath(path, paint);
  }

跑出来的效果图如下:

移动会闪一下.gif

  咦,满脸的嫌弃,这是什么东西啊,空白虽然没了,为什么会卡一下,并且这个也不是我们想要的效果。我们再想一想,我们这个无线循环的动画的原理是什么? 敲黑板,其实,我们就是多画了一个正弦波形,我们移动之后,跟移动之前一样,也就是位移了两个正弦图,结束后的图形,跟结束前的图形重合,然后一直重复动画,从而让用户感觉是无线循环的动画。 辣么,哪里出问题呢?想一想,为什么达不到我们的效果,肯定是我们水平移动距离的有问题啊。找啊找啊找,找到了,我们这里的endx坐标,是一个完整正弦图形的一半。所以,我们动画移动的距离要乘以2。如下:

currentStartX = (int) (2 * endX * animatedValue);//动画里面的
//动画还要加上插值器,从而达到平滑的效果
animator.setInterpolator(new LinearInterpolator());

辣么,这里距离变了,我们起始点的距离,循环的距离也要变。要不然会有空白
path.moveTo(-endX*2 + currentStartX, startY);

for (int i = -endX*2; i < getWidth() + endX*2; i += 2 * endX) {...}

  综上所述,去除无关代码之后的完整代码,如下:

/**
 * Author: 海晨忆
 * Date: 2018/3/27
 * Desc:
 */
public class WaveView1 extends View {
  private Path path;
  private Paint paint;
  private Paint paint1;

  private int startY = 300;

  private int endX = 300;

  private int controlY = 150;

  private int currentStartX;

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

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

  public WaveView1(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initView();
  }

  private void initView() {
    path = new Path();
    paint = new Paint();
    paint1 = new Paint();
    paint.setColor(Color.GREEN);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(10);
    paint1.set(paint);
    paint1.setColor(Color.RED);
  }

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    path.reset();
    path.moveTo(-endX*2 + currentStartX, startY);
    for (int i = -endX*2; i < getWidth() + endX*2; i += 2 * endX) {
      path.rQuadTo(endX / 2, -controlY, endX, 0);
      path.rQuadTo(endX / 2, controlY, endX, 0);
    }
    canvas.drawPath(path, paint);
  }

  public void startAnimation() {
    ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
    animator.setDuration(1000);
    animator.setRepeatCount(ValueAnimator.INFINITE);
    animator.setInterpolator(new LinearInterpolator());
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        Float animatedValue = (Float) animation.getAnimatedValue();
        currentStartX = (int) (2 * endX * animatedValue);
        postInvalidate();
      }
    });
    animator.start();
  }
}

跑出来的效果图如下:

移动水波纹.gif

完美达到了我们的预期效果。

第三步:自定义View上下移动

  经过上面的左右动画,现在这个上下移动的动画就很简单了,很明显是改变起始点y坐标的值,当然,肯定是属性动画。代码如下:

public void startAnimation() {
    
    ...//这是我们的左移动画,没写上来
    
    //这就是我们的竖着移动的动画
    ValueAnimator animator1 = ValueAnimator.ofFloat(0, 1);
    animator1.setDuration(5000);
    animator1.setInterpolator(new LinearInterpolator());
    animator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        Float animatedValue = (Float) animation.getAnimatedValue();
        currentStartY = (int) (getHeight() * animatedValue);
        postInvalidate();
      }
    });
    animator1.start();
  }
  
  //改变我们的初始点的y坐标。不要只写个currentStartY,
  //光写这个是从下往上移动,你要的是从上往下移动
  path.moveTo(-endX*2 + currentStartX, getHeight()-currentStartY);
  
  //再就是修改画笔为填充
  paint.setStyle(Paint.Style.FILL_AND_STROKE);
  
  //并且把path连接成一个闭合图形
  ...//这里是onDraw里面的for循环画正弦图形
  path.lineTo(getWidth(),getHeight());
    path.lineTo(0,getHeight());
    path.close();
    canvas.drawPath(path, paint);

跑出来的效果图,如下:

上下移动有bug.gif

  到这个位置,基本上已经完成了百分之九十了。我们可以看到开始会有一个问题,结束的时候也有一个问题,这个问题是怎么产生的呢?

第四步:优化开始和结束的动画

  其实,我们可以想一想,一开始,我们这个控制点的Y值,不应该一出来就是写死的,显得太突兀了,一开始,我们应该是慢慢涨,涨到我们规定的值,然后快结束的时候,我们应该是慢慢减,减到0为止。应该是这样才对。

@Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    path.reset();
    int currentControlY = 0;
    if (currentStartY < controlY) {
      currentControlY = currentStartY;
    } else {
      currentControlY = controlY;
    }
    if (getHeight() - currentStartY < controlY) {
      currentControlY = getHeight() - currentStartY;
    }
    path.moveTo(-endX * 2 + currentStartX, getHeight() - currentStartY);
    for (int i = -endX * 2; i < getWidth() + endX * 2; i += 2 * endX) {
      path.rQuadTo(endX / 2, -currentControlY, endX, 0);
      path.rQuadTo(endX / 2, currentControlY, endX, 0);
    }
    path.lineTo(getWidth(), getHeight());
    path.lineTo(0, getHeight());
    path.close();
    canvas.drawPath(path, paint);
  }

效果图如下:

最终效果图.gif

经过上面的操作,就完美的达到了我们的预期效果。(PS:把画笔的宽度去掉)

把这个自定义View优化一下,把方法封装好了。项目链接

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

推荐阅读更多精彩内容