参考文章:
目录
注:请不要边看边复制代码,以下代码不全。建议缕清整个绘制流程后,到自定义View之炫酷的水滴ViewPageIndicator中去看完整代码。写这篇文章的原因是这个github上的代码注释很少,自己也琢磨了很久。为了巩固知识,加深理解,才有了这篇文章。
1. 效果图展示
2. 绘制流程
2.1 onMeasure测量大小
这一步我们直接调用父类的onMeasure(widthMeasureSpec, heightMeasureSpec)
方法就好了,不需要过多的干涉。 代码如下:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
复制代码
2.2 onLayout 摆放位置
通过图片,我们可知,现在两圆间距
,圆的半径
(半径可以有一个默认值,然后开放自定义属性让外界传入)都已知,但是缺少图片宽度
这个重要参数。
其实,我们可以这么想,这个图片的大小,肯定是不能超过圆的半径的,而且,为了协调,它最好是个正方形。所以,我们可以让外界传入一个比例值,让使用者规定图片的大小要相对于圆的半径多大,这样,图片的宽度就可以确定了,而且也可以确保图片一定在圆圈之内。
so,图片的宽度可以这么计算:
int picWidth = scale * radius ;//scale就是传入的比例
复制代码
好了,图片的宽度已知了,那么我们的摆放位置也就可以求了:
- 首先在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将圆和图片绘制出来
在测量好大小,确定好位置后,我们就可以拿起我们的画笔,进行绘制图形。
- 初始化画笔
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);
}
复制代码
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>
复制代码
运行起来的界面效果如下:
2.4 绘制点击图形的波纹效果
点击后的效果如图:
这段动画的原理就是在一段时间内,不断改变圆的半径。 这个效果很容易做,难点应该是如何知道点击的是哪个圆,以及如何确定动画执行的圆的中心点。 那么如何知道是哪个圆呢?
从图中可以看出第一个圆的范围是:
spacing < x < spacing + radius * 2
复制代码
依次类推,我们可以得出: 第N个圆的点击范围是:
(N-1)* spacing + radius * 2 < x< N*spacing + radius * 2
复制代码
明白原理后,应该我们就可以开始写了。。。
- 初始化画笔
mClickPaint = new Paint();
mClickPaint.setColor(Color.YELLOW);
mClickPaint.setStyle(Paint.Style.STROKE);
mClickPaint.setAntiAlias(true);
mClickPaint.setStrokeWidth(radius / 2);
复制代码
- 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);
}
复制代码
- 启动动画
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;
}
复制代码
- 根据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 利用贝塞尔曲线绘制移动动画
现在,最难的部分来了。 先放一张最后实现的动画效果。
关于三阶贝塞尔曲线的内容,这里不进行展开,请先阅读文章: 安卓自定义View进阶 - 贝塞尔曲线 三阶贝塞尔曲线的变化规律,用图形来表示,就是如下:
通过观察最终的效果,我们可以发现,其实圆的状态就是以下两种:
分别是状态1,和状态2。(当从左向右运动时)要实现这两种状态,首先要了解如何通过贝塞尔曲线画圆。 用贝塞尔曲线绘制一个圆需要12个点,如图所示。
现在,我们写一个demo来测试一下,当改变点的位置以及改变m的大小,会对圆的形状造成什么影响。 首先我们用三阶贝塞尔曲线绘制出了一个圆,此时:
mc = 0.552284749831;
m = 圆的半径(demo里是200) * mc;
复制代码
至于为什么是0.551915024494f
,请查看答案。 此时,我们得到的圆是这样的:
ok,现在我们改变p2的点试试看效果(p2也就是右边的点):
可以看到,我们可以通过修改p2的位置,使得图形更扁。
好了。接下来,我们修改m
的大小:
可以看到,通过修改m的大小,可以改变圆两点之间的弧度,也就是m越大时,两点之间的弧度越扁平。
现在,我们一点点来分析移动过程中图形状态的变化。 首先,我们假设圆从一个点移动到另外一个点用了1s的时间。 现在,
- 当 0 < t < 0.2s的时候,当前的形状是: 可以看到,其实就是把p2点慢慢向右移动,最终移动到半径的两倍的位置; 用代码表示:
if (mCurrentTime > 0 && mCurrentTime <= 0.2) {
//画布向右移动,方便进行绘制圆球
canvas.translate(startX,startY);
//p2向右移动
p2.setX(radius + 2 * 5 * mCurrentTime * radius / 2);
}
复制代码
- 当 0.2 < t < 0.5s时,
此时圆开始向右移动,移动的距离是多少呢?答案是:
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);
}
复制代码
- 当0.5 < t < 0.8s时 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);
}
复制代码
- 当0.8 < t < 0.9s时 左边的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);
}
复制代码
- 当0.9 < t <1s时 这个阶段是处理回弹,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,至此,本文结束。谢谢观看。