贝塞尔曲线模仿练习

参考文章:

目录

目录.png

注:请不要边看边复制代码,以下代码不全。建议缕清整个绘制流程后,到自定义View之炫酷的水滴ViewPageIndicator中去看完整代码。写这篇文章的原因是这个github上的代码注释很少,自己也琢磨了很久。为了巩固知识,加深理解,才有了这篇文章。

1. 效果图展示

效果图展示.gif

2. 绘制流程

2.1 onMeasure测量大小

这一步我们直接调用父类的onMeasure(widthMeasureSpec, heightMeasureSpec)方法就好了,不需要过多的干涉。 代码如下:

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

2.2 onLayout 摆放位置

分析.png

通过图片,我们可知,现在两圆间距,圆的半径(半径可以有一个默认值,然后开放自定义属性让外界传入)都已知,但是缺少图片宽度这个重要参数。

其实,我们可以这么想,这个图片的大小,肯定是不能超过圆的半径的,而且,为了协调,它最好是个正方形。所以,我们可以让外界传入一个比例值,让使用者规定图片的大小要相对于圆的半径多大,这样,图片的宽度就可以确定了,而且也可以确保图片一定在圆圈之内。

扫描二维码关注公众号,回复: 856372 查看本文章

so,图片的宽度可以这么计算:

int picWidth = scale * radius ;//scale就是传入的比例

好了,图片的宽度已知了,那么我们的摆放位置也就可以求了:

  1. 首先在onSizeChanged保存一些需要重复使用到的数值(比如:间距)
 @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        spacing = (w - 2 * tabNum * radius) / (tabNum + 1);
        startX = spacing + radius;
        startY = h / 2;
        super.onSizeChanged(w, h, oldw, oldh);
    }

这里的startX是第一个圆的中点x坐标,startY是第一个圆的中点y坐标。 这里没有直接记录控件的宽和高,因为通过第一个圆来求其他圆的位置会容易点。 2. 在onLayout中确定各个圆的位置

  private float scale = 0.5f;
 @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        tabNum = getChildCount();
        for (int i = 0; i < tabNum; i++) {
            View child = getChildAt(i);
            child.layout((int) (spacing + (1 - scale * 1) * radius + i * (spacing + 2 * radius)),
                    (int) (startY - scale * radius ),
                    (int) (spacing + (1 + scale * 1 ) * radius + i * (spacing + 2 * radius)),
                    (int) (startY + scale * radius ));
        }
    }

这里如果看 自定义View之炫酷的水滴ViewPageIndicator源码的话,会看到,它的计算方法中还会除以一个g2变量,这个g2变量等于1.41421。 他的代码如下:

  child.layout((int) (div + (1 - scale * 1 / g2) * radius + i * (div + 2 * radius)), 
                    (int) (startY - scale * radius / g2), 
                    (int) (div + (1 + scale * 1 / g2) * radius + i * (div + 2 * radius)),
                    (int) (startY + scale * radius / g2));

div就是我们的spacing,也就是两圆间距。 作者这么做的原因未知,希望知道的人留言告知一下。多谢~

2.3 dispatchDraw将圆和图片绘制出来

在测量好大小,确定好位置后,我们就可以拿起我们的画笔,进行绘制图形。

  1. 初始化画笔
private float radius = 50;
public MyDropIndicator(Context context) {
        super(context);
        init();
    }
public MyDropIndicator(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
private void init() {
        mPaintCircle = new Paint();
        mPaintCircle.setColor(Color.RED);
        mPaintCircle.setStyle(Paint.Style.STROKE);
        mPaintCircle.setAntiAlias(true);
        mPaintCircle.setStrokeWidth(3);
}
  1. dispatchDraw方法绘制图形
 protected void dispatchDraw(Canvas canvas) {
        tabNum = getChildCount();
        for (int i = 0; i < tabNum; i++) {
            canvas.drawCircle(spacing + radius + i * (spacing + 2 * radius), startY, radius, mPaintCircle);
        }
.....
}

到这一步,基本的界面应该就可以看到了。可以测试一下:

public class TestActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test);
    }
}
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <comulez.github.droplibrary.MyDropIndicator
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/camera" />

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/video" />

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/notice" />

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/msg" />
    </comulez.github.droplibrary.MyDropIndicator>
</LinearLayout>

运行起来的界面效果如下:

效果.png

2.4 绘制点击图形的波纹效果

点击后的效果如图:

录屏.gif

这段动画的原理就是在一段时间内,不断改变圆的半径。 这个效果很容易做,难点应该是如何知道点击的是哪个圆,以及如何确定动画执行的圆的中心点。 那么如何知道是哪个圆呢?

位置.png

从图中可以看出第一个圆的范围是:

spacing < x < spacing + radius * 2

依次类推,我们可以得出: 第N个圆的点击范围是:

(N-1)* spacing + radius * 2 < x< N*spacing + radius * 2

明白原理后,应该我们就可以开始写了。。。

  1. 初始化画笔
mClickPaint = new Paint();
mClickPaint.setColor(Color.YELLOW);
mClickPaint.setStyle(Paint.Style.STROKE);
mClickPaint.setAntiAlias(true);
mClickPaint.setStrokeWidth(radius / 2);
  1. onTouchEvent中处理点击事件
    public boolean onTouchEvent(MotionEvent event){
        float x = event.getX();
        if (x > spacing + 2 * radius && x < (spacing + 2 * radius) * tabNum) {
            int toPos = (int) (x / (spacing + 2 * radius));
            if (toPos != currentPos && toPos <= tabNum) {
                startAniTo(currentPos, toPos);
            }
        } else if (x > spacing && x < spacing + 2 * radius) {
            if (currentPos != 0) {
                startAniTo(currentPos, 0);
            }
        }
        return super.onTouchEvent(event);
    }
  1. 启动动画
   private boolean startAniTo(int currentPos, int toPos) {
        this.currentPos = currentPos;
        this.toPos = toPos;
        if (currentPos == toPos) {
            return true;
        }
        if (animator == null) {
            animator = ValueAnimator.ofFloat(0, 1.0f);
            animator.setDuration(1000);
            animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    //重点,在draw的时候会根据currentTime进行变换半径
                    mCurrentTime = (float) animation.getAnimatedValue();
                    invalidate();
                }
            });
       
        }
        animator.start();
        return true;
    }
  1. 根据currentTime进行绘制不同半径的圆
protected void dispatchDraw(Canvas canvas) {
   //...省略
 if (mCurrentTime > 0 && mCurrentTime <= 0.2) {
            canvas.drawCircle(spacing + radius + (toPos) * (spacing + 2 * radius), startY, radius * 1.0f * 5 * mCurrentTime, mClickPaint);
        }
 //....省略
}

到这一步,点击效果就完成了。。

2.5 利用贝塞尔曲线绘制移动动画

现在,最难的部分来了。 先放一张最后实现的动画效果。

移动动画.gif

关于三阶贝塞尔曲线的内容,这里不进行展开,请先阅读文章: 安卓自定义View进阶 - 贝塞尔曲线 三阶贝塞尔曲线的变化规律,用图形来表示,就是如下:

三阶贝塞尔曲线.gif

通过观察最终的效果,我们可以发现,其实圆的状态就是以下两种:

状态.png
分别是状态1,和状态2。(当从左向右运动时)

要实现这两种状态,首先要了解如何通过贝塞尔曲线画圆。 用贝塞尔曲线绘制一个圆需要12个点,如图所示。

2018-05-07_222224.png

现在,我们写一个demo来测试一下,当改变点的位置以及改变m的大小,会对圆的形状造成什么影响。 首先我们用三阶贝塞尔曲线绘制出了一个圆,此时:

mc = 0.552284749831;
m = 圆的半径(demo里是200) * mc;

至于为什么是0.551915024494f,请查看答案。 此时,我们得到的圆是这样的:

初始.png

ok,现在我们改变p2的点试试看效果(p2也就是右边的点):

修改p2.gif

可以看到,我们可以通过修改p2的位置,使得图形更扁。

好了。接下来,我们修改m的大小:

修改m.gif

可以看到,通过修改m的大小,可以改变圆两点之间的弧度,也就是m越大时,两点之间的弧度越扁平。

现在,我们一点点来分析移动过程中图形状态的变化。 首先,我们假设圆从一个点移动到另外一个点用了1s的时间。 现在,

  1. 当 0 < t < 0.2s的时候,当前的形状是:
    状态1.1.png
    可以看到,其实就是把p2点慢慢向右移动,最终移动到半径的两倍的位置; 用代码表示:
 if (mCurrentTime > 0 && mCurrentTime <= 0.2) {
            //画布向右移动,方便进行绘制圆球
            canvas.translate(startX,startY);
           //p2向右移动
            p2.setX(radius + 2 * 5 * mCurrentTime * radius / 2);
        }
  1. 当 0.2 < t < 0.5s时,
    2018-05-07_225316.png
    此时圆开始向右移动,移动的距离是多少呢?答案是:startX + (t- 0.2f) * distance / 0.7f。 那为啥是除以0.7呢?因为0到0.2没平移,0.2到0.9平移完成,0.9到1处理回弹。平移时间只有0.9-0.2=0.7,这段时间要完成一个distance的距离的平移。同时之前圆向右凸起时,p2组的点x坐标总共增加了一个radius(这个决定凸起程度)。现在要把它弄回对称椭圆,所以p1组和p3组的点要右移半个radius,同时mc调整一下使椭圆不那么尖; 用代码表示:
if (mCurrentTime > 0.2f && mCurrentTime <= 0.5f){
            canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f,startY);
            p2.setX(2 * radius);
            p1.setX(((mCurrentTime - 0.2f) * 0.5f * radius / 0.3f));
            p3.setX(((mCurrentTime - 0.2f) * 0.5f * radius / 0.3f));
            p2.setMc(mc + (mCurrentTime - 0.2f) * mc /4 / 0.3f);
            p4.setMc(mc + (mCurrentTime - 0.2f) * mc /4 / 0.3f);
        }
  1. 当0.5 < t < 0.8s时
    状态2.png
    p1和p3的X坐标继续往右移,mc逐渐重置为原来大小,效果就是圆的最右端固定不变,左边的凸起缩回去。 用代码表示:
 if (mCurrentTime > 0.5f && mCurrentTime <= 0.8f){
            //开始恢复原始形状
            canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f,startY);

            p1.setX(0.5f * radius + 0.5f * radius* (mCurrentTime - 0.5f) / 0.3f);
            p3.setX(0.5f * radius + 0.5f * radius* (mCurrentTime - 0.5f) / 0.3f);
            p2.setMc(1.25f * mc - 0.25f * mc * (mCurrentTime - 0.5f) / 0.3f);
            p4.setMc(1.25f * mc - 0.25f * mc * (mCurrentTime - 0.5f) / 0.3f);
        }
  1. 当0.8 < t < 0.9s时
    状态3.png
    左边的p4.组点往右平移过头,圆形成凹陷。 用代码表示:
 if (mCurrentTime > 0.8 && mCurrentTime <= 0.9) {
            p2.setMc(mc);
            p4.setMc(mc);
            canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f, startY);
           p4.setX(-radius + 1.6f * radius * (mCurrentTime - 0.8f) / 0.1f);
        } 
  1. 当0.9 < t <1s时
    状态4.png
    这个阶段是处理回弹,p4.组点x逐渐恢复正常。表现为回弹恢复为标准圆。 用代码表示:
if (mCurrentTime > 0.9 && mCurrentTime < 1) {
                p1.setX(radius);
                p3.setX(radius);
                canvas.translate(startX + distance, startY);
                p4.setX(0.6f * radius - 0.6f * radius * (mCurrentTime - 0.9f) / 0.1f);
          

注意,这里的代码都是默认球是从左向右运动的,明白了从左向右运动的规律后,从右向左其实也就不难了。为了节省代码,只粘贴了从左向右运动的。

OK,至此,本文结束。谢谢观看。

猜你喜欢

转载自juejin.im/post/5af5340e5188254267261b4a
今日推荐