Android自定義View(8)-利用貝塞爾曲線實現(xiàn)直播小心形點贊效果

照樣先看效果:

Screenrecorder-2021-07-06-12-38-47-134[1]2021761250152.gif

這個效果的實現(xiàn)跟上一篇文章仿QQ消息拖拽
效果的設(shè)計結(jié)構(gòu)差不多。同樣使用到了貝塞爾曲線公式,通過 WindowManager 將一個自定義的 Layout 添加到 Window,然后在 這個自定義的Layout 里實現(xiàn)動畫效果。
這里小心形向上的運動路徑是一條控制點隨機的3階貝塞爾曲線,曲線上的點利用了貝塞爾曲線公式通過自定義的估值器 TypeEvaluator 生成。

一、貝塞爾應(yīng)用分析

這里也不對貝塞爾曲線的原理進行分析,只針對此次效果進行應(yīng)用分析。
先看百度百科對貝塞爾曲線的解釋:貝塞爾曲線_百度百科

3階貝塞爾.png

上圖是百度百科里的三階貝塞爾曲線公式,小心形運動路徑就是由三階公式和估值器生成。公式里有4個點:P0、P1、P2、P3。其中P0和P3分別是起始點和終點,P1和P2是兩個控制點,公式當中的 t 是一個進度值,在曲線運動當中會從0 變到 1。下面分析小心形運動曲線路徑:
三階貝塞爾曲線.png

上圖是小心形運動軌跡與4個 P點的關(guān)系,P1 和 P2 作為控制點,只用于控制曲線運動的方向。只要確定 4 個點的值,代入三階公式即可用估值器求出小心形運動的三階曲線路徑。

現(xiàn)在分析4個點的坐標。首先起始點 P0 和終點 P3 很明顯,P0的橫坐標取布局寬度Width的一半,縱坐標取高度 Height 即可。P3 橫坐標取0 到 Width之間的隨機數(shù),縱坐標是 0。然后控制點的選取。因為每條曲線都不一樣,所以控制點要隨機選取。所以控制點P1、P2的橫坐標 X 取0 到 Width的隨機數(shù)即可。現(xiàn)在曲線的效果要求控制點 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 ,生成三階貝塞爾曲線

估值器實現(xiàn)代碼如下:

/**
 * 自定義估值器,計算貝塞爾曲線
 *
 */
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 ??刂泣c P1 和 P2 則在構(gòu)造方法里傳入。(注:evaluate 方法的參數(shù) 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 監(jiān)聽返回值里,可以連續(xù)拿到三階曲線的點值 bezierPoint 以及參數(shù) Fraction(即三階公式里的 t),這樣就可以連續(xù)改變小心形 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;
        // 設(shè)置不可點點擊,這里不能主動放棄焦點,否則按返回鍵回到桌面會導致窗體泄露
//        mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
        mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
        addFlowerLayout();
    }

    /**
     * 這里監(jiān)聽返回鍵,移除 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 當中添加小心形,并實現(xiàn)動畫效果
     */
    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) {
                // 動畫結(jié)束,移除小心形
                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);
    }

    /**
     * 獲取狀態(tài)欄高度
     *
     * @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;
    }

    /**
     * 創(chuàng)建并獲取View的Bitmap
     *
     * @param view view
     * @return
     */
    public Bitmap getViewBitmap(View view) {
        view.buildDrawingCache();
        return view.getDrawingCache();
    }
}

三、性能優(yōu)化

最后還有性能優(yōu)化的兩個點想記錄一下。

(1)及時移除子View

效果里的每一顆小心形都是一個加載的ImageView,所以每次點擊就會往布局里 add 一個View。因此,在動畫結(jié)束時要及時移除ImageView。這既是性能上的需求,也是效果上的需求。所以上面代碼里對屬性動畫的執(zhí)行過程進行了監(jiān)聽:

togetherAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
            }
            @Override
            public void onAnimationEnd(Animator animation) {
                // 動畫結(jié)束,移除小心形
                removeView(loveImage);
            }
            @Override
            public void onAnimationCancel(Animator animation) {
            }
            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
(2)為避免窗體泄露,初始化布局時不能放棄焦點。因此要及時移除 Window 中的布局,動畫結(jié)束時及時釋放焦點。

因為這個自定義布局是通過 windowManage的addView添加到 Window上的,所以這個布局就類似依賴于 Activity 的dialog。在往 window當中添加布局的時候可以設(shè)置以下參數(shù):

 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 這個參數(shù)不能設(shè)置。
但這樣又會導致另外一個問題,就是Window 中的 Layout 獲得了焦點,攔截了返回鍵,但如果Layout 不處理返回事件,那點返回鍵就出現(xiàn)始終無效果的現(xiàn)象。解決的辦法是,在Layout里監(jiān)聽返回鍵:

 /**
     * 這里監(jiān)聽返回鍵,移除 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();
        }
    }

上面兩個方法,一個是獲取返回鍵事件,一個是監(jiān)聽小心形移除完畢。當點擊返回鍵時,或者界面已經(jīng)沒有小心形時,就將這個自定義的 Layout 從 Window中移除。這樣就可以及時釋放焦點,把焦點還給 Activity。當重新點贊時,再重新把自定義點贊 Layout添加到Window 中。這樣,就可以優(yōu)化性能,而不至于導致效果偏差。
Demo在:Github源碼

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容