1.写在文前
按照惯例,反手就是一个超链接:
github地址
2.目标
本文要实现的View效果如下图:
3.分析
从效果图容易看出,图中的功能主要分为两个部分:
- 左侧大拇指动画
- 右侧的文字动画
3.1 左侧(PraiseView)
不难发现左侧动画效果主要由三部分组成:
- MotionEvent_DOWN时的拇指缩小,UP时的放大效果
- MotionEvent_UP时的圆圈扩散效果(水波纹效果)
- MotionEvent_UP时的上面的四条线段效果
拇指的缩放各位客观想必也是心中有数的,无非就是两种方式:
- 对整个View使用scale动画
- 对View中的VectorDrawable使用scale动画
细心的客观已经发现了当四条线段存在的时候,点击之后,线段也是会随之缩放的。没错,豆豆正是对整个View进行了scale处理。
代码如下:
// 处理拇指缩放效果
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
move = event.getY();
animate().scaleY(0.8f).scaleX(0.8f).start();
break;
case MotionEvent.ACTION_UP:
getHandler().postDelayed(new Runnable() {
@Override
public void run() {
animate().cancel();
setScaleX(1);
setScaleY(1);
}
}, 300);
...
// 省略无关代码
break;
}
return super.onTouchEvent(event);
}
3.1.1 圆圈扩散
没错,就是画圈圈。同样,仔细的同志应该已经发现了些什么,冥冥之中似乎有些什么不可告人的秘密。
是的,这里有两个需要注意的地方:
- 初始圆圈的半径,和中心位置,也就是圈圈该画在哪里(从图中不难看出,圆圈是包裹着拇指的)
- measure出View的大小,确认drawable的bound(不自行measure确定view的大小的话,默认的大小是只会包裹drawable哦~)
废话不多说,先看代码:
// 测量View宽高
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
...
switch (widthSpecMode) {
...
case MeasureSpec.AT_MOST:
widthMeasureSpec = mDrawable.getIntrinsicWidth();
break;
...
}
switch (heightSpecMode) {
...
// wrap_content
case MeasureSpec.AT_MOST:
heightMeasureSpec = mDrawable.getIntrinsicHeight();
break;
...
}
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
initDrawable(mDrawable, widthMeasureSpec, heightMeasureSpec);
initPointFs(1.3f);
}
// drawable的大小为view的0.6
private void initDrawable(Drawable drawable, int width, int height) {
mCircleCenter.x = width / 2f;
mCircleCenter.y = height / 2;
mDrawable = drawable;
// drawable的边长为view的0.6
float diameter = (float) ((width > height ? height : width) * 0.6);
int left = (int) ((width - diameter)/2);
int top = (int)(height - diameter)/2;
int right = (int) (left + diameter);
int bottom = (int) (top + diameter);
Rect drawableRect = new Rect(left, top, right, bottom);
mDrawable.setBounds(drawableRect);
requestLayout();
}
由此计算出了view和drawable的大小,从而可以去画他了。这样我们就确认了圈圈该画在哪里,接下来的扩散效果,只需要控制圈圈的半径即可,依旧看代码:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mDrawable.draw(canvas);
drawEffect(canvas);
}
private void drawEffect(Canvas canvas) {
// 画圆
if (mRadius > 0)
canvas.drawCircle(mCircleCenter.x, mCircleCenter.y, mRadius, mPaint);
if (drawLines == 1) {
// 划线
...
}
public void animation() {
final float radius = getInitRadius(mDrawable);
ObjectAnimator animator = ObjectAnimator.ofFloat(this, "radius", radius, radius * 1.5f, radius * 3.0f);
animator.setInterpolator(new AnticipateInterpolator());
animator.setDuration(500);
// 画线
// ...
set.start();
}
至此我们完成了拇指的缩放和波纹效果,心里美滋滋有木有
3.1.2 线段效果
线段怎么去画呢?中小学老师告诉我们,两点确认一条线段。问题随之转换:
- 那么我们如何确认这两点的位置呢?
- 为了可持续发展,我们该怎么样去确定两条线段直接的距离呢?
各位客官,不妨喝杯茶,吃点瓜子,思考下上面这个问题。
...
... // 优雅的喝茶timing
...
细心的朋友已经注意到我之前的onMeasure方法中有一个initPointFs(1.3);没错,就是在获取View的大小后,进行了对点的计算,看代码:
/**
* 用于计算 线条的长度
* @param scale 外圆半径为内圆半径的scale倍数
*/
private void initPointFs(float scale) {
mPointList.clear();
float radius = getInitRadius(mDrawable);
int base = -60;
int factor = -20;
for (int i = 0; i < 4; i++) {
int result = base + factor * i;
// 点p1为mDrawable外接圆上的点
PointF p1 = new PointF(
mCircleCenter.x + (float) (radius * Math.cos(Math.toRadians(result))),
mCircleCenter.y + (float) (radius * Math.sin(Math.toRadians(result)))
);
// 点p1为mDrawable外接圆scale倍上的点
PointF p2 = new PointF(
mCircleCenter.x + (float) (scale * radius * Math.cos(Math.toRadians(result))),
mCircleCenter.y + (float) (scale * radius * Math.sin(Math.toRadians(result)))
);
mPointList.add(p1);
mPointList.add(p2);
}
}
通过代码注解不难发现,这里我们巧妙的利用同心圆和角度的方式来确定了4条线段,8个点集合的值(豆豆不禁感叹,数学对程序员的重要性)。这样做的好处就是足够灵活,无论View大小如何变,线段的间隔和长短都是适宜的。
至此左侧的拇指动画效果,算是告一段落了。
3.2 右侧(RecordView)
右边的数字翻牌效果,乍看起来很简单,无非就是drawText()累加之后重新drawText();原理上是这样的没错,不过值得注意的是:
- 无需变化的数位上的值不会被翻动
- 上下翻动时前一个数字会渐渐隐掉
先看,测量过程:
从图中我们不难发现,测量的高度值应当为Text的3倍,用于显示前一个,当前,和下一个的数字值
宽度可以直接从api中获取当前的text的宽即可,看代码:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
···
switch (widthSpecMode) {
···
case MeasureSpec.AT_MOST:
int width = (int) mPaint.measureText("0", 0, 1) * mCurrentString.length();
widthMeasureSpec = width;
break;
···
}
switch (heightSpecMode) {
···
case MeasureSpec.AT_MOST:
mTextHeight = mPaint.getFontSpacing();
heightMeasureSpec = (int) (mTextHeight * 3);
break;
case MeasureSpec.EXACTLY:
mPaint.setTextSize(heightSpecSize / 4);
mTextHeight = (int) mPaint.getFontSpacing();
heightMeasureSpec = heightSpecSize;
break;
}
pointY = 2 * mTextHeight;
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
}
在测量出View的宽高之后,便要着手去画view的内容了,而内容很简单,就是一系列的String值。到这里都比较容易实现,而难点则是,确定上一个和下一个值,以及他们的位置。
细心的朋友可能已经发现在measure的时候,我们有一个mTextHeigh记录了文字的高度,pointY记录了两倍文字的高度,没错这里就是利用mTextHeight来控制三个可能要画出来的string值的位置的。
这里有必要提一下的是,drawText(@NonNull String text, float x, float y, @NonNull Paint paint)这个方法中的float y对应的是baseLine的y值,简单的理解的话就是一串String的bottom的位置,画出来的内容是在bottom之上的。这也是为什么我们要用pointY = 2 * mTextHeight的理由。至此不难想到,我们的lastNum, currentNum, NextNum画的位置,分别对应mTextHeight, 2 * mTextHeight和3 * mTextHeight。至此三个值的位置便算是确定好了。
3.2.1 加1动画
先看加1的处理,上代码:
public void addOne() {
mCurrentString = String.valueOf(mCurrentNum);
mCurrentNum++;
mNextString = String.valueOf(mCurrentNum);
mStatus = ADD;
// 数字位数进1
if (mCurrentString.length() < mNextString.length()) {
mCurrentString = " " + mCurrentString;
requestLayout();
}
ObjectAnimator animator = ObjectAnimator.ofFloat(this, "pointY", 2 * mTextHeight, mTextHeight);
ObjectAnimator alphaAnim = ObjectAnimator.ofInt(this, "paintAlpha", 255, 0);
AnimatorSet set = new AnimatorSet();
set.playTogether(alphaAnim, animator);
set.start();
}
代码比较简单,无非是做了移动和透明度的动画效果,这里便解决了“上下翻动时前一个数字会渐渐隐掉”的需求,需要注意的点是,数字位进1时的利用空格占位的处理,不做该处理,当数字进位后,动画效果会差强人意,有兴趣的朋友可以去试试看。
结合onDraw方法再来看看:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mStatus == NONE) {
canvas.drawText(mCurrentString, 0, pointY, mPaint);
} else if (mStatus == ADD) {
for (int i = mNextString.length() - 1; i >= 0; i--) {
String next = String.valueOf(mNextString.charAt(i));
String current = String.valueOf(mCurrentString.charAt(i));
// i位置需要改变
if (!next.equals(current)) {
mPaint.setAlpha(mPaintAlpha);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, pointY, mPaint);
// mPaintAlpha : 255 - 0 递减
mPaint.setAlpha(255 - mPaintAlpha);
canvas.drawText(next, mPaint.measureText("0", 0, 1) * i, mTextHeight + pointY, mPaint);
// i位置不需要改变
} else {
mPaint.setAlpha(255);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight * 2, mPaint);
}
}
} else if (mStatus == REDUCE) {
// pointY是累加的,因此有个往下滑动效果
for (int i = mCurrentString.length() - 1; i >= 0; i--) {
String last = String.valueOf(mLastString.charAt(i));
String current = String.valueOf(mCurrentString.charAt(i));
// i位置需要改变
if (!last.equals(current)) {
mPaint.setAlpha(mPaintAlpha);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight + pointY, mPaint);
// mPaintAlpha : 255 - 0 递减
mPaint.setAlpha(255 - mPaintAlpha);
canvas.drawText(last, mPaint.measureText("0", 0, 1) * i, pointY, mPaint);
// i位置不需要改变
} else {
mPaint.setAlpha(255);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight * 2, mPaint);
}
}
}
}
这里便是核心所在了:如何无需变化的数位上的值不会被翻动?
onDraw方法中给出了我们答案,思路很简单:
- 将接下来要显示的数字和当前的正在显示的数字的每一位数一一对比如果不同,则通过动画效果重画,相同,则不走动画效果,直接画出来即可。
至此gif图中的两部分效果都已经实现
3.3 整体(PraiseRecordView)
以上是分开独立的两个view,为了更方便的使用这个效果,我们需要将两个view的功能整合在一起,起到一个联动效果,也就需要引入一个ViewGroup去确定这两个view(PraiseView和RecordView)的布局,这部分主要涉及到layout,以及viewgroup测绘的时候,使用的是match_parent宽高时,如何控制子view的显示,有兴趣的朋友可以直接去看代码,这里暂不做赘述了。同时
4 总结
行文至此,我不禁点了根黄鹤楼,望着那袅袅的烟,一抬手摸着了天...
天边飘来一个:
github地址