Android自定义View(8)-利用贝塞尔曲线实现直播小心形
照样先看效果:
Screenrecorder-2021-07-06-12-38-47-134[1]2021761250152.gif这个效果的实现跟上一篇文章仿QQ消息拖拽
效果的设计结构差不多。同样使用到了贝塞尔曲线公式,通过 WindowManager 将一个自定义的 Layout 添加到 Window,然后在 这个自定义的Layout 里实现动画效果。
这里小心形向上的运动路径是一条控制点随机的3阶贝塞尔曲线,曲线上的点利用了贝塞尔曲线公式通过自定义的估值器 TypeEvaluator 生成。
一、贝塞尔应用分析
这里也不对贝塞尔曲线的原理进行分析,只针对此次效果进行应用分析。
先看百度百科对贝塞尔曲线的解释:贝塞尔曲线_百度百科
上图是百度百科里的三阶贝塞尔曲线公式,小心形运动路径就是由三阶公式和估值器生成。公式里有4个点:P0、P1、P2、P3。其中P0和P3分别是起始点和终点,P1和P2是两个控制点,公式当中的 t 是一个进度值,在曲线运动当中会从0 变到 1。下面分析小心形运动曲线路径:
三阶贝塞尔曲线.png
上图是小心形运动轨迹与4个 P点的关系,P1 和 P2 作为控制点,只用于控制曲线运动的方向。只要确定 4 个点的值,代入三阶公式即可用估值器求出小心形运动的三阶曲线路径。
现在分析4个点的坐标。首先起始点 P0 和终点 P3 很明显,P0的横坐标取布局宽度Width的一半,纵坐标取高度 Height 即可。P3 横坐标取0 到 Width之间的随机数,纵坐标是 0。然后控制点的选取。因为每条曲线都不一样,所以控制点要随机选取。所以控制点P1、P2的横坐标 X 取0 到 Width的随机数即可。现在曲线的效果要求控制点 P1 要在P2之下,即P1y > P2y。所以这里P1的纵坐标y 取 Height / 2 到Height,P2的纵坐标取 0到 Height。下面是4个点的坐标范围:
P0(Width / 2 , Height)
P1 (0 < x < Width , Height / 2 < y < Height)
P2(0 < x < Width , 0 < y < Height / 2)
P3 (0 < x < Width , 0)
二、自定义估值器 TypeEvaluator ,生成三阶贝塞尔曲线
估值器实现代码如下:
/**
* 自定义估值器,计算贝塞尔曲线
*
*/
public class BezierEvaluator implements TypeEvaluator<PointF> {
private PointF controlPoint1, controlPoint2;
/**
* 传入控制点
*
* @param cp1 控制点1
* @param cp2 控制点2
*/
public BezierEvaluator(PointF cp1, PointF cp2){
this.controlPoint1 = cp1;
this.controlPoint2 = cp2;
}
/**
* 贝塞尔三次方公式
*
* @param fraction fraction的范围是0~1
* @param P0 起始点
* @param P3 终点
* @return 曲线值
*/
@Override
public PointF evaluate(float fraction, PointF P0, PointF P3) {
PointF pathPoint = new PointF();
// 贝塞尔三次方公式
pathPoint.x = P0.x * (1 - fraction) * (1 - fraction)* (1 - fraction) +
3 * controlPoint1.x * fraction * (1 - fraction) * (1 - fraction) +
3 * controlPoint2.x * fraction * fraction * (1 - fraction) +
P3.x * fraction * fraction * fraction;
pathPoint.y = P0.y * (1 - fraction) * (1 - fraction)* (1 - fraction) +
3 * controlPoint1.y * fraction * (1 - fraction) * (1 - fraction) +
3 * controlPoint2.y * fraction * fraction * (1 - fraction) +
P3.y * fraction * fraction * fraction;
return pathPoint;
}
}
可以看到,重写的方法 evaluate 里返回了起始点 P0 和终点 P3 。控制点 P1 和 P2 则在构造方法里传入。(注:evaluate 方法的参数 fraction 就是曲线方程里的 t)
下面是估值器的使用方法:
/**
* 使用自定义估值器生成贝塞尔曲线
*
* @param view
* @return
*/
private ValueAnimator getBezierAnimator(View view) {
// 求控制点
PointF p1 = new PointF(mRandom.nextInt(mWidth), mRandom.nextInt(mHeight / 2) + mHeight / 2);
PointF p2 = new PointF(mRandom.nextInt(mWidth), mRandom.nextInt(mHeight / 2));
// 求起始点和终点
PointF P0 = new PointF(mWidth / 2 - bitmapWidth / 2,
mHeight - getStatusBarHeight(mApplicationContext) - bitmapHeight / 2);
PointF P3 = new PointF(mRandom.nextInt(mWidth), 0);
ValueAnimator valueAnimator = new ValueAnimator();
BezierEvaluator bezierEvaluator = new BezierEvaluator(p1, p2);
valueAnimator.setEvaluator(bezierEvaluator);
valueAnimator.setObjectValues(P0, P3);
valueAnimator.setDuration(3000);
valueAnimator.setInterpolator(new DecelerateInterpolator());
valueAnimator.addUpdateListener((ValueAnimator animator) -> {
// 自定义估值器BezierEvaluator的贝塞尔公式算出的 point
PointF bezierPoint = (PointF) animator.getAnimatedValue();
view.setX(bezierPoint.x);
view.setY(bezierPoint.y);
view.setAlpha((float) (1 - animator.getAnimatedFraction() + 0.2));
});
return valueAnimator;
}
这个方法写在自定义布局 LoveFlowerView 里。可以看到,在属性动画 ValueAnimator 监听返回值里,可以连续拿到三阶曲线的点值 bezierPoint 以及参数 Fraction(即三阶公式里的 t),这样就可以连续改变小心形 view 的坐标以及透明度 alpha。下面是自定义View 的完整代码:
/**
* 小心形直播点赞效果
*
* Ethan Lee
*/
public class LoveFlowerView extends ConstraintLayout {
private static Context mApplicationContext = FlowerApplication.getFlowerApplicationContext();
private ConstraintLayout.LayoutParams mParams;
private WindowManager mWindowManager;
private WindowManager.LayoutParams mWindowParams;
private static final int[] loveImages = {R.mipmap.love_blue, R.mipmap.love_red, R.mipmap.love_yellow};
private Random mRandom = new Random();
private int mWidth = 1;
private int mHeight = 1;
private AnimatorSet togetherAnimator;
private int bitmapWidth = 0;
private int bitmapHeight = 0;
// 是否已往window添加layout
private boolean flowerLayoutIsAdd = false;
public LoveFlowerView(@NonNull @NotNull Context context) {
this(context, null);
}
public LoveFlowerView(@NonNull @NotNull Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public LoveFlowerView(@NonNull @NotNull Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initRes(context, attrs, defStyleAttr);
}
private void initRes(Context context, AttributeSet attrs, int defStyleAttr) {
// 初始化时添加 layout 只是为了测量宽高
initWindowManager(context);
mParams = new Constraints.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
mParams.bottomToBottom = PARENT_ID;
mParams.leftToLeft = PARENT_ID;
mParams.rightToRight = PARENT_ID;
post(() -> {
mWidth = getWidth();
mHeight = getHeight();
// 宽高测量完后移除,避免点返回键五任何效果
removeFlowerLayout();
});
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.love_blue);
if (bitmap != null) {
bitmapWidth = bitmap.getWidth();
bitmapHeight = bitmap.getHeight();
bitmap.recycle();
}
}
/**
* 初始化 WindowManager 并将 layout 添加到 Window
*
* @param context
*/
private void initWindowManager(Context context){
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
mWindowParams = new WindowManager.LayoutParams();
mWindowParams.format = PixelFormat.TRANSPARENT;
// 设置不可点点击,这里不能主动放弃焦点,否则按返回键回到桌面会导致窗体泄露
// mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
addFlowerLayout();
}
/**
* 这里监听返回键,移除 Window 中的 layout,释放焦点。否则窗体占用焦点,按返回键无效
*
* @param event
* @return
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getKeyCode() == KeyEvent.KEYCODE_BACK){
Log.d("tag", "getKeyCode = " + event.getKeyCode());
removeFlowerLayout();
}
return super.dispatchKeyEvent(event);
}
/**
* 小心形移除完之后也及时移除 layout ,释放焦点,否则按返回键无效
*
* @param view
*/
@Override
public void onViewRemoved(View view) {
super.onViewRemoved(view);
if (getChildCount() == 0){
removeFlowerLayout();
}
}
/**
* 往 Window添加 layout 并做标记
*/
private void addFlowerLayout(){
if(!flowerLayoutIsAdd) {
mWindowManager.addView(this, mWindowParams);
flowerLayoutIsAdd = true;
}
}
/**
* 移除 layout 释放资源
*/
public void removeFlowerLayout(){
if (flowerLayoutIsAdd){
if (togetherAnimator != null ) {
togetherAnimator.cancel();
}
mWindowManager.removeView(this);
removeAllViews();
flowerLayoutIsAdd = false;
}
}
/**
* 往 layout 当中添加小心形,并实现动画效果
*/
public void addFlowerView() {
addFlowerLayout();
ImageView loveImage = new ImageView(mApplicationContext);
loveImage.setImageResource(loveImages[mRandom.nextInt(loveImages.length)]);
addView(loveImage, mParams);
togetherAnimator = getAllAnimator(loveImage);
togetherAnimator.start();
togetherAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
// 动画结束,移除小心形
removeView(loveImage);
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
}
private AnimatorSet getAllAnimator(View view) {
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playSequentially(getAnimatorSet(view), getBezierAnimator(view));
return animatorSet;
}
/**
* 使用自定义估值器生成贝塞尔曲线
*
* @param view
* @return
*/
private ValueAnimator getBezierAnimator(View view) {
// 求控制点
PointF p1 = new PointF(mRandom.nextInt(mWidth), mRandom.nextInt(mHeight / 2) + mHeight / 2);
PointF p2 = new PointF(mRandom.nextInt(mWidth), mRandom.nextInt(mHeight / 2));
// 求起始点和终点
PointF P0 = new PointF(mWidth / 2 - bitmapWidth / 2,
mHeight - getStatusBarHeight(mApplicationContext) - bitmapHeight / 2);
PointF P3 = new PointF(mRandom.nextInt(mWidth), 0);
ValueAnimator valueAnimator = new ValueAnimator();
BezierEvaluator bezierEvaluator = new BezierEvaluator(p1, p2);
valueAnimator.setEvaluator(bezierEvaluator);
valueAnimator.setObjectValues(P0, P3);
valueAnimator.setDuration(3000);
valueAnimator.setInterpolator(new DecelerateInterpolator());
valueAnimator.addUpdateListener((ValueAnimator animator) -> {
// 自定义估值器BezierEvaluator的贝塞尔公式算出的 point
PointF bezierPoint = (PointF) animator.getAnimatedValue();
view.setX(bezierPoint.x);
view.setY(bezierPoint.y);
view.setAlpha((float) (1 - animator.getAnimatedFraction() + 0.2));
});
return valueAnimator;
}
private AnimatorSet getAnimatorSet(View view) {
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(getAlphaAnimator(view), getScaleAnimatorX(view),
getScaleAnimatorY(view));
return animatorSet;
}
private ObjectAnimator getAlphaAnimator(View loveImage) {
return ObjectAnimator.ofFloat(loveImage, "alpha", (float) 0.1, 1).setDuration(500);
}
private ObjectAnimator getScaleAnimatorX(View loveImage) {
return ObjectAnimator.ofFloat(loveImage, "scaleX", (float) 0.1, 1).setDuration(500);
}
private ObjectAnimator getScaleAnimatorY(View loveImage) {
return ObjectAnimator.ofFloat(loveImage, "scaleY", (float) 0.1, 1).setDuration(500);
}
private ObjectAnimator getTranslationObjectX(View loveImage) {
return ObjectAnimator.ofFloat(loveImage, "translationX", 0, 18).setDuration(1000);
}
private ObjectAnimator getTranslationObjectY(View loveImage) {
return ObjectAnimator.ofFloat(loveImage, "translationY", 0, -888).setDuration(1000);
}
/**
* 获取状态栏高度
*
* @param context
* @return
*/
public int getStatusBarHeight(Context context) {
int height = 0;
int resId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resId > 0) {
height = context.getResources().getDimensionPixelSize(resId);
}
Log.d("StatusBarUtil", "StatusBarHeight = " + height);
return height;
}
/**
* 创建并获取View的Bitmap
*
* @param view view
* @return
*/
public Bitmap getViewBitmap(View view) {
view.buildDrawingCache();
return view.getDrawingCache();
}
}
三、性能优化
最后还有性能优化的两个点想记录一下。
(1)及时移除子View
效果里的每一颗小心形都是一个加载的ImageView,所以每次点击就会往布局里 add 一个View。因此,在动画结束时要及时移除ImageView。这既是性能上的需求,也是效果上的需求。所以上面代码里对属性动画的执行过程进行了监听:
togetherAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
// 动画结束,移除小心形
removeView(loveImage);
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
(2)为避免窗体泄露,初始化布局时不能放弃焦点。因此要及时移除 Window 中的布局,动画结束时及时释放焦点。
因为这个自定义布局是通过 windowManage的addView添加到 Window上的,所以这个布局就类似依赖于 Activity 的dialog。在往 window当中添加布局的时候可以设置以下参数:
mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
意思就是不获取焦点且不可点击,这样布局就没有焦点,也不会拦截返回键。当点击返回键时布局不拦截,而是传给了底层的 Activity。这样就不影响 activity的退出,这个逻辑似乎正确,但会造成窗体泄露。原因是add 到Window 中的布局是依赖于 Activity的,持有其上下文。就像是一个dialog一样,Activity退出了,窗的界面还在,那就造成了泄露。所以,在往 Window 中 addView 时,WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE 这个参数不能设置。
但这样又会导致另外一个问题,就是Window 中的 Layout 获得了焦点,拦截了返回键,但如果Layout 不处理返回事件,那点返回键就出现始终无效果的现象。解决的办法是,在Layout里监听返回键:
/**
* 这里监听返回键,移除 Window 中的 layout,释放焦点。否则窗体占用焦点,按返回键无效
*
* @param event
* @return
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getKeyCode() == KeyEvent.KEYCODE_BACK){
Log.d("tag", "getKeyCode = " + event.getKeyCode());
removeFlowerLayout();
}
return super.dispatchKeyEvent(event);
}
/**
* 小心形移除完之后也及时移除 layout ,释放焦点,否则按返回键无效
*
* @param view
*/
@Override
public void onViewRemoved(View view) {
super.onViewRemoved(view);
if (getChildCount() == 0){
removeFlowerLayout();
}
}
上面两个方法,一个是获取返回键事件,一个是监听小心形移除完毕。当点击返回键时,或者界面已经没有小心形时,就将这个自定义的 Layout 从 Window中移除。这样就可以及时释放焦点,把焦点还给 Activity。当重新点赞时,再重新把自定义点赞 Layout添加到Window 中。这样,就可以优化性能,而不至于导致效果偏差。
Demo在:Github源码