照樣先看效果:

這個效果的實現(xiàn)跟上一篇文章仿QQ消息拖拽
效果的設(shè)計結(jié)構(gòu)差不多。同樣使用到了貝塞爾曲線公式,通過 WindowManager 將一個自定義的 Layout 添加到 Window,然后在 這個自定義的Layout 里實現(xiàn)動畫效果。
這里小心形向上的運動路徑是一條控制點隨機的3階貝塞爾曲線,曲線上的點利用了貝塞爾曲線公式通過自定義的估值器 TypeEvaluator 生成。
一、貝塞爾應(yīng)用分析
這里也不對貝塞爾曲線的原理進行分析,只針對此次效果進行應(yīng)用分析。
先看百度百科對貝塞爾曲線的解釋:貝塞爾曲線_百度百科

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

上圖是小心形運動軌跡與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源碼