Android

Android自定义View(8)-利用贝塞尔曲线实现直播小心形

2021-07-06  本文已影响0人  碧云天EthanLee
照样先看效果:
Screenrecorder-2021-07-06-12-38-47-134[1]2021761250152.gif
这个效果的实现跟上一篇文章仿QQ消息拖拽
效果的设计结构差不多。同样使用到了贝塞尔曲线公式,通过 WindowManager 将一个自定义的 Layout 添加到 Window,然后在 这个自定义的Layout 里实现动画效果。
这里小心形向上的运动路径是一条控制点随机的3阶贝塞尔曲线,曲线上的点利用了贝塞尔曲线公式通过自定义的估值器 TypeEvaluator 生成。

一、贝塞尔应用分析

这里也不对贝塞尔曲线的原理进行分析,只针对此次效果进行应用分析。
先看百度百科对贝塞尔曲线的解释:贝塞尔曲线_百度百科

3阶贝塞尔.png
上图是百度百科里的三阶贝塞尔曲线公式,小心形运动路径就是由三阶公式和估值器生成。公式里有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源码

上一篇下一篇

猜你喜欢

热点阅读