Android 自定义 View | 指示牌弹出动效

574 阅读5分钟

Android 自定义 View.png

相关推荐:

1. 简述

这次来实现一个简单的动画,算是动画方面的实践了。要实现的效果如下:

这是一个指示牌,每切换一次,就会从底部重新弹出来,并且文字也改变。动画简单,适合入门。

2. 动画分解

动画在一个Rect:240px*290px的方形区域内进行(这里以1280*720的尺寸为例),其有三个主体:指示牌,指示牌杆,指示牌文字。动画时间是350毫秒,速度是先加速后减速。

指示牌杆:

一开始位于底部,露出一点,并且有顺时针角度的倾斜,偏离X轴中央的右边,然后从底部弹出来,升到快到最高处,然后下落,最后到达终点,终点是在X轴的中央,杆的底部贴紧动画区域的底部,并且杆的倾斜度变成0°。

  • 1)从偏离X轴中央的右边移动到终点,这是X轴方向的位移
  • 2)弹出到落下,这是Y轴方向的位移变化
  • 3)倾斜角度X到倾斜角度为0,这是旋转角度的变化

指示牌:

指示牌也是跟指示牌杆同样的动画,一开始在动画区域的底下,不可见,然后弹出来,具体来看,指示牌是没有X轴方向的位移,整个过程都是在动画区域X轴中央,只有Y轴方向的位移,和旋转角度变化

指示牌文字:

文字随着指示牌,同样的动画,不过要注意文字绘制的起点。

3. 简单版实现

我们设置一个动画完成度因子:changeFactor,用ObjectAnimator去改变因子的值,从而带动每一个主体的运动。

private float changeFactor = 0;
private final float FACTOR_END = 100;
private final float FACTOR_START = 0;

objectAnimator = ObjectAnimator.ofFloat(this, "changeFactor", FACTOR_START, FACTOR_END);
objectAnimator.setDuration(350);
objectAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
objectAnimator.start();

changeFactor代表着整个动画的进度,进度是从0-100,别忘了要设置对应的getter/setter方法,在setter方法里面,通知view重绘。

public void setChangeFactor(float changeFactor) {
    this.changeFactor = changeFactor;
    invalidate();
}
public float getChangeFactor() {
    return changeFactor;
}

指示牌杆

指示牌杆有三种变化,组合起来就是最终的效果,我们先看第一种:

  • 这里我们加载指示牌杆: bitmapTrunk = BitmapFactory.decodeResource(getResources(), R.drawable.car_sign_trunk);
  • 从偏离X轴中央的右边移动到终点,这是X轴方向的位移,从UI提供的具体数据,变化是从距离X轴中心位置的右边移动到中央,移动了bitmapTrunk.getWidth() / 2,而我们的changeFactor也就是动画完成度是从0->100,需要映射到0->bitmapTrunk.getWidth() / 2,所以需要一个映射函数:
 /**
  * 因子映射
  * @param currentFactor 原因子的值
  * @param origStartFactor 原因子的上限
  * @param origEndFactor 原因子的下限
  * @param startFactor 新因子的上限
  * @param endFactor 新因子的下限
  * @return 新因子的值
  */
public static float factorMapping(float currentFactor, float origStartFactor, float origEndFactor, float startFactor, float endFactor) {
    return (currentFactor - origStartFactor) * (endFactor - startFactor) / (origEndFactor - origStartFactor) + startFactor;
}
  • 应用到onDraw里面,为了更好地说明,绘制了一个动画区域:RectF rectFSign = new RectF(0,0,240,290),因为考虑到后面是需要旋转,位移等动画,所以,这里用Matrix去绘制Bitmap
//绘制动画区域
paint.setColor(Color.GRAY);
canvas.translate(100,20);
canvas.drawRect(rectFSign,paint);

// bitmapTrunk
//横向位移,bitmapTrunk width/2
matrix.reset();
float xOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, 0, bitmapTrunk.getWidth() / 2);
matrix.postTranslate(rectFSign.right/2 - xOffset, rectFSign.bottom);
canvas.drawBitmap(bitmapTrunk, matrix, paint);
  • 出来的效果如下:

接着看第二种,是Y轴的位移

  • 我们以绘制区域左上角为原点,杆的Y轴运动就是:从开始(杆顶距离绘制区域bottom的35px处)y=255,向上移动到y=5的位置,然后再向下移动到y=120的位置,所以呢,这里我们又得对changeFactorMapping到y=255 -> y=5 -> 120,这里Mapping变化的中间有一个插值,所以不能直接使用上面的函数,要改造一下, 这算是一个数学问题了:给出两个变化区域A:(origStartFactor,origEndFactor),B:(startFactor,interpolatorFactor,endFactor)和区域A当前的值currentFactor,B区域有一个插值,即其变化是:startFacotr -> interpolatorFactor -> endFactor,求出,区域A当前的值currentFactor,所对应的B区域的当前值 currentFactor ? 这里给出我写的一个答案:
/**
 * 因子映射
 * @param currentFactor 原因子的值
 * @param origStartFactor 原因子的上限
 * @param origEndFactor 原因子的下限
 * @param startFactor 新因子的上限
 * @param interpolatorFactor 新因子的插值
 * @param endFactor 新因子的下限
 * @return 新因子的值
 */
public static float factorMapping(float currentFactor, float origStartFactor, float origEndFactor,
                                  float startFactor, float interpolatorFactor, float endFactor) {
    //现在的百分比
    float temp = (currentFactor - origStartFactor) / (origEndFactor - origStartFactor);
    //新的总大小
    float newFactorMax = Math.abs(startFactor-interpolatorFactor) + Math.abs(endFactor-interpolatorFactor);
    //上限所占的百分比
    float startTemp = Math.abs(startFactor-interpolatorFactor) / newFactorMax;
    if (startTemp<temp) {
        //当前限所占百分比
        float tempX = Math.abs( (temp - startTemp) * newFactorMax / Math.abs(endFactor-interpolatorFactor));
        return tempX * (endFactor-interpolatorFactor) + interpolatorFactor;
    }
    else {
        //当前限所占百分比
        float tempX = Math.abs(temp * newFactorMax / Math.abs(startFactor-interpolatorFactor));
        return tempX * (interpolatorFactor-startFactor) + startFactor;
    }
}
  • 应用到onDraw里面
// bitmapTrunk
//横向位移,bitmapTrunk width/2
matrix.reset();
float xOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, 0, bitmapTrunk.getWidth() / 2);
//纵向位移,( rectFSign.bottom - 35 , 5 , 120 )
float yOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, rectFSign.bottom - 35, 5, 120);
matrix.postTranslate(rectFSign.right/2 - xOffset, yOffset);
canvas.drawBitmap(bitmapTrunk, matrix, paint);

第三种也是一样

  • 旋转从21°,我们要基于bitmap进行中心旋转,封装一下matrix的旋转操作:
/**
 * 将旋转应用到矩阵
 *  此旋转是基于Bitmap的中心旋转
 *
 * @param bitmap 位图对象
 * @param rotation 旋转度数
 * @param posX canvas绘制Bitmap的起点
 * @param posY canvas绘制Bitmap的起点
 * @param matrix matrix
 */
public static void applySelfRotationToMatrix(Bitmap bitmap, float rotation, float posX, float posY, Matrix matrix) {
    float offsetX = bitmap.getWidth() / 2;
    float offsetY = bitmap.getHeight() / 2;
    matrix.postTranslate(-offsetX, -offsetY);
    matrix.postRotate(rotation);
    matrix.postTranslate(posX + offsetX, posY + offsetY);
}
  • 应用到onDraw
// bitmapTrunk
//横向位移,bitmapTrunk width/2
float xOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, 0, bitmapTrunk.getWidth() / 2);
//纵向位移,( rectFSign.bottom - 35 , 5 , 120 )
float yOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, rectFSign.bottom - 35, 5, 120);
//旋转,21° - 0°,逆时针
float rotationOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, 21, 0);
matrix.reset();
ViewUtils.applySelfRotationToMatrix(bitmapTrunk, rotationOffset,rectFSign.right / 2 - xOffset, yOffset, matrix);
canvas.drawBitmap(bitmapTrunk, matrix, paint);

指示牌

有了上面的分析,指示牌也是一样的:

 // bitmapCover
 //旋转,-10° - 0°,逆时针
 rotationOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, -10, 0);
 
 //纵向位移,( rectFSign.bottom , 80 , 108 )
 yOffset = ViewUtils.factorMapping(changeFactor, FACTOR_START, FACTOR_END, rectFSign.bottom, 80, 108);
 
 matrix.reset();
 ViewUtils.applySelfRotationToMatrix(bitmapCover, rotationOffset,rectFSign.right / 2 - bitmapCover.getWidth() / 2, yOffset, matrix);
 canvas.drawBitmap(bitmapCover, matrix, paint);

指示牌文字

  • 文字需要跟着指示牌一起动,也就是旋转,位移,所以普通的drawText就不行了,我们使用drawTextOnPath,这方法是在一条path上写文本,只要在指示牌的相应位置画一条path,path上就写文本,然后让这path随着指示牌一起动,也就是文本也随着动了。为了说明,放出一张灵魂画图:

  • 如果,指示牌没有旋转,那么其文本path就是蓝色的AB线(大致位置,后面可以微调),A,B点的坐标也非常容易得到,而旋转我们是使用了基于指示牌的中心旋转,也就是基于M点的旋转,那么蓝色的AB线也应该是基于M点旋转相同的角度得到对应的红色A'B'线,那么我们怎么样获取到A',B'点的坐标呢?这又是一个数学问题了:在坐标平面上,给出一点A(ax,ay),和M(mx,my),求A点基于M点旋转任意角度θ后的A'坐标。
/**
 * 在平面中,一个点绕任意点旋转 radian 弧度
 *  后的点的坐标
 *
 * @param rX0 旋转基准点x
 * @param rY0 旋转基准点y
 * @param radian 旋转radian
 * @param x 旋转点x
 * @param y 旋转点y
 * @return 旋转的结果,x,y
 */
public static float pointRotateGetX(float rX0, float rY0, float radian, float x, float y) {
    return (float) ((x - rX0) * Math.cos(radian) - (y - rY0) * Math.sin(radian) + rX0);
}
public static float pointRotateGetY(float rX0, float rY0, float radian, float x, float y) {
    return (float) ((x - rX0) * Math.sin(radian) + (y - rY0) * Math.cos(radian) + rY0);
}
  • 来先画一条Path,旋转一下看看:
//左下点 A
float x0 = rectFSign.right / 2 - bitmapCover.getWidth() / 2;
float y0 = yOffset + bitmapCover.getHeight();
//旋转点 M
float rX0 = x0 + bitmapCover.getWidth() / 2;
float rY0 = y0 - bitmapCover.getHeight() / 2;
//右下点 B
float x1 = x0 + bitmapCover.getWidth();
float y1 = y0;
//旋转弧度
float radians = (float) Math.toRadians(rotationOffset);
//A'
path.moveTo(ViewUtils.pointRotateGetX(rX0, rY0, radians, x0, y0), ViewUtils.pointRotateGetY(rX0, rY0, radians, x0, y0));
//B'
path.lineTo(ViewUtils.pointRotateGetX(rX0, rY0, radians, x1, y1), ViewUtils.pointRotateGetY(rX0, rY0, radians, x1, y1));
//A'B'线
paint.setColor(Color.RED);
paint.setStrokeWidth(5);
canvas.drawPath(path,paint);

  • 嗯,差不多了,微调一下,把文字写上去就可以了:
// str 的绘制 path
paint.setColor(Color.RED);
path.reset();
//左下点
float x0 = rectFSign.right / 2 - bitmapCover.getWidth() / 2;
float y0 = yOffset + bitmapCover.getHeight();
//旋转点
float rX0 = x0 + bitmapCover.getWidth() / 2;
float rY0 = y0 - bitmapCover.getHeight() / 2;
//右下点
float x1 = x0 + bitmapCover.getWidth();
float y1 = y0;
float radians = (float) Math.toRadians(rotationOffset);
path.moveTo(ViewUtils.pointRotateGetX(rX0, rY0, radians, x0, y0), ViewUtils.pointRotateGetY(rX0, rY0, radians, x0, y0));
path.lineTo(ViewUtils.pointRotateGetX(rX0, rY0, radians, x1, y1), ViewUtils.pointRotateGetY(rX0, rY0, radians, x1, y1));
paint.setColor(Color.WHITE);
paint.setTextSize(signStrSize);
if (typeface != null)
    paint.setTypeface(typeface);
float hOffset = (bitmapCover.getWidth() - paint.measureText(signStr)) / 2;
canvas.drawTextOnPath(signStr, path, hOffset, -20, paint);

详情可以看Demo

4. 优雅版实现

  • 上面的写完后,总感觉不太优雅,好像很多函数很多API都自己写,难道原生的没有吗?查了一下,自己还是太年轻了,原生早就有这样的一套动画API。

  • 我们的动画涉及到诸多属性值的变化,而且是共享动画时间和插值器,所以这里应该使用AnimatorSet属性动画集合去做。PropertyValuesHolder是动画集合的元素,代表一个属性值。 这里直接贴出代码,主要是要确定好,属性值的变化区域即可:
//指示牌杆X轴的位移
PropertyValuesHolder trunkXPropertyValuesHolder =
        PropertyValuesHolder.ofFloat("trunkX",trunkX, trunkX-bitmapTrunk.getWidth()/2);
//指示牌杆Y轴的位移
PropertyValuesHolder trunkYPropertyValuesHolder =
        PropertyValuesHolder.ofFloat("trunkY",trunkY,5,120);
//指示牌杆的旋转变化
PropertyValuesHolder trunkRotationDegreePropertyValuesHolder =
        PropertyValuesHolder.ofFloat("trunkRotationDegree",trunkRotationDegree,0);
//指示牌Y轴的位移
PropertyValuesHolder coverYPropertyValuesHolder =
        PropertyValuesHolder.ofFloat("coverY",coverY,80,108);
//指示牌Y轴的旋转变化
PropertyValuesHolder coverRotationDegreePropertyValuesHolder =
        PropertyValuesHolder.ofFloat("coverRotationDegree",coverRotationDegree,0);
//添加到动画集合
ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(this,
        trunkXPropertyValuesHolder,trunkYPropertyValuesHolder,trunkRotationDegreePropertyValuesHolder,
        coverYPropertyValuesHolder,coverRotationDegreePropertyValuesHolder);
animator.setDuration(350);
animator.setInterpolator(new AccelerateDecelerateInterpolator());
animator.addUpdateListener(valueAnimator -> CarAdvancedSignView.this.invalidate());
animator.start();
  • 这里,你还要声明对应的一系列的属性值:trunkX,trunkY,trunkRotationDegree ...和对应的getter/setter方法。注意到,动画属性值更新的时候,会逐一调用每一个属性值的setter方法,当所有的属性值都更新完一遍后,会调用:
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator valueAnimator) {
        
    }
});
  • 所以,你需要在onAnimationUpdate里面去主动刷新。

  • 文字的话,跟上面一样的思路。

  • 最后的效果:

效果是差不多的,CPU的表现也是差不多的,代码量,啧啧啧...

详情可以看Demo

5. 总结

  • 通过这个动画,熟悉了 drawTextOnPath 的使用,当文字有动画效果的时候(主要是位移,旋转),就可以考虑用这个思路
  • 更加熟悉了AnimatorSet的使用

码字不易,方便的话素质三连,或者关注我的公众号 技术酱,专注 Android 技术,不定时推送新鲜文章,如果你有好的文章想和大家分享,欢迎关注投稿!

技术酱