1. 程式人生 > >Android模仿微信浮窗功能的效果實現

Android模仿微信浮窗功能的效果實現

最近研究了微信懸浮窗的效果實現,寫此文章記錄一下,後面有我的GitHub原始碼地址。
老規矩,先放效果圖,效果如下所示:

這裡寫圖片描述

首先,說下專案的主要幾個功能點。
1.app申請懸浮窗許可權,通過WindowManager新增檢視
2.一共新增三個檢視,右下角兩個檢視,分別表示小刪除檢視和大刪除檢視,一個是真正的浮窗檢視
3.webView消失動畫效果實現

我的整個專案,是在這個專案https://github.com/yhaolpz/FloatWindow的基礎上新增和修改的,還是要感謝之前的大神的無私奉獻啊。

申請許可權,該專案實現了一個工具類,對於小米手機不同的系統版本,需要專門去適配,下面來判斷通過哪種方式申請許可權:
FloatPhone.init()

@Override
    public void init() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
            req();
        } else if (Miui.rom()) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                req();
            } else {
                mLayoutParams.type = WindowManager.LayoutParams.TYPE_PHONE;
                Miui.req(mContext, new PermissionListener() {
                    @Override
                    public void onSuccess() {
                        mWindowManager.addView(mView, mLayoutParams);
                        if (mPermissionListener != null) {
                            mPermissionListener.onSuccess();
                        }
                    }

                    @Override
                    public void onFail() {
                        if (mPermissionListener != null) {
                            mPermissionListener.onFail();
                        }
                    }
                });
            }
        } else {
            try {
                mLayoutParams.type = WindowManager.LayoutParams.TYPE_TOAST;
                mWindowManager.addView(mView, mLayoutParams);
            } catch (Exception e) {
                mWindowManager.removeView(mView);
                LogUtil.e("TYPE_TOAST 失敗");
                req();
            }
        }
    }

如果是小米手機,用以下方式申請許可權

package com.yhao.floatwindow;

import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.view.View;
import android.view.WindowManager;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

import static com.yhao.floatwindow.Rom.isIntentAvailable;

/**
 * Created by yhao on 2017/12/30.
 * https://github.com/yhaolpz
 * <p>
 * 需要清楚:一個MIUI版本對應小米各種機型,基於不同的安卓版本,但是許可權設定頁跟MIUI版本有關
 * 測試TYPE_TOAST型別:
 * 7.0:
 * 小米      5        MIUI8         -------------------- 失敗
 * 小米   Note2       MIUI9         -------------------- 失敗
 * 6.0.1
 * 小米   5                         -------------------- 失敗
 * 小米   紅米note3                  -------------------- 失敗
 * 6.0:
 * 小米   5                         -------------------- 成功
 * 小米   紅米4A      MIUI8         -------------------- 成功
 * 小米   紅米Pro     MIUI7         -------------------- 成功
 * 小米   紅米Note4   MIUI8         -------------------- 失敗
 * <p>
 * 經過各種橫向縱向測試對比,得出一個結論,就是小米對TYPE_TOAST的處理機制毫無規律可言!
 * 跟Android版本無關,跟MIUI版本無關,addView方法也不報錯
 * 所以最後對小米6.0以上的適配方法是:不使用 TYPE_TOAST 型別,統一申請許可權
 */

class Miui {

    private static final String miui = "ro.miui.ui.version.name";
    private static final String miui5 = "V5";
    private static final String miui6 = "V6";
    private static final String miui7 = "V7";
    private static final String miui8 = "V8";
    private static final String miui9 = "V9";
    private static List<PermissionListener> mPermissionListenerList;
    private static PermissionListener mPermissionListener;


    static boolean rom() {
        LogUtil.d(" Miui  : " + Miui.getProp());
        return Build.MANUFACTURER.equals("Xiaomi");
    }

    private static String getProp() {
        return Rom.getProp(miui);
    }

    /**
     * Android6.0以下申請許可權
     */
    static void req(final Context context, PermissionListener permissionListener) {
        if (PermissionUtil.hasPermission(context)) {
            permissionListener.onSuccess();
            return;
        }
        if (mPermissionListenerList == null) {
            mPermissionListenerList = new ArrayList<>();
            mPermissionListener = new PermissionListener() {
                @Override
                public void onSuccess() {
                    for (PermissionListener listener : mPermissionListenerList) {
                        listener.onSuccess();
                    }
                    mPermissionListenerList.clear();
                }
                @Override
                public void onFail() {
                    for (PermissionListener listener : mPermissionListenerList) {
                        listener.onFail();
                    }
                    mPermissionListenerList.clear();
                }
            };
            req_(context);
        }
        mPermissionListenerList.add(permissionListener);
    }


    private static void req_(final Context context) {
        switch (getProp()) {
            case miui5:
                reqForMiui5(context);
                break;
            case miui6:
            case miui7:
                reqForMiui67(context);
                break;
            case miui8:
            case miui9:
                reqForMiui89(context);
                break;
        }
        FloatLifecycle.setResumedListener(new ResumedListener() {
            @Override
            public void onResumed() {
                if (PermissionUtil.hasPermission(context)) {
                    mPermissionListener.onSuccess();
                } else {
                    mPermissionListener.onFail();
                }
            }
        });
    }


    private static void reqForMiui5(Context context) {
        String packageName = context.getPackageName();
        Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
        Uri uri = Uri.fromParts("package", packageName, null);
        intent.setData(uri);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        if (isIntentAvailable(intent, context)) {
            context.startActivity(intent);
        } else {
            LogUtil.e("intent is not available!");
        }
    }

    private static void reqForMiui67(Context context) {
        Intent intent = new Intent("miui.intent.action.APP_PERM_EDITOR");
        intent.setClassName("com.miui.securitycenter",
                "com.miui.permcenter.permissions.AppPermissionsEditorActivity");
        intent.putExtra("extra_pkgname", context.getPackageName());
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        if (isIntentAvailable(intent, context)) {
            context.startActivity(intent);
        } else {
            LogUtil.e("intent is not available!");
        }
    }

    private static void reqForMiui89(Context context) {
        Intent intent = new Intent("miui.intent.action.APP_PERM_EDITOR");
        intent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.PermissionsEditorActivity");
        intent.putExtra("extra_pkgname", context.getPackageName());
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        if (isIntentAvailable(intent, context)) {
            context.startActivity(intent);
        } else {
            intent = new Intent("miui.intent.action.APP_PERM_EDITOR");
            intent.setPackage("com.miui.securitycenter");
            intent.putExtra("extra_pkgname", context.getPackageName());
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            if (isIntentAvailable(intent, context)) {
                context.startActivity(intent);
            } else {
                LogUtil.e("intent is not available!");
            }
        }
    }


    /**
     * 有些機型在新增TYPE-TOAST型別時會自動改為TYPE_SYSTEM_ALERT,通過此方法可以遮蔽修改
     * 但是...即使成功顯示出懸浮窗,移動的話也會崩潰
     */
    private static void addViewToWindow(WindowManager wm, View view, WindowManager.LayoutParams params) {
        setMiUI_International(true);
        wm.addView(view, params);
        setMiUI_International(false);
    }


    private static void setMiUI_International(boolean flag) {
        try {
            Class BuildForMi = Class.forName("miui.os.Build");
            Field isInternational = BuildForMi.getDeclaredField("IS_INTERNATIONAL_BUILD");
            isInternational.setAccessible(true);
            isInternational.setBoolean(null, flag);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


}

獲取到許可權後,就開始新增檢視了。這裡主要說下,右下角的小的消除檢視,因為他有個動畫效果,從右下角底部移動到某個座標點,動畫實現方式如下所示:

 private void showWithAnimator(final boolean isShow) {
        if (xCancelOffset == 0) {
            IFloatWindow cancelWindow = FloatWindow.get("cancel");
            if (cancelWindow != null) {
                int[] array = cancelWindow.getOffset();
                xCancelOffset = array[0];
                yCancelOffset = array[1];
            }
        }
        if (xCoordinate == 0) {
            xCoordinate = Util.getScreenWidth(mB.mApplicationContext);
            yCoordinate = Util.getScreenHeight(mB.mApplicationContext);
        }
        ValueAnimator mAnimator = new ValueAnimator();
        mAnimator.setDuration(500);
        if (isShow) {
            mAnimator.setObjectValues(new PointF(xCoordinate, yCoordinate), new PointF(xCancelOffset, yCancelOffset));
        } else {
            mAnimator.setObjectValues(new PointF(xCancelOffset, yCancelOffset), new PointF(xCoordinate, yCoordinate));
        }

        mAnimator.setEvaluator(new TypeEvaluator<PointF>() {
            @Override
            public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
                int valueX = (int) (startValue.getX() + fraction * (endValue.getX() - startValue.getX()));
                int valueY = (int) (startValue.getY() + fraction * (endValue.getY() - startValue.getY()));
                return new PointF(valueX, valueY);
            }
        });
        mAnimator.start();
        mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                PointF point = (PointF) valueAnimator.getAnimatedValue();

                mFloatView.updateXY(point.getX(), point.getY());

            }
        });
    }

第二個難點是,webView的消失動畫效果。

我試過很多次,webView想要實現一個圓角的漸變動畫,很難實現,所以最後我選擇了一個替代方法,就是先將webView的檢視獲取到,設定到ImageView中,然後將ImageView設定相應的動畫即可:

程式碼如下所示:

package demo.com.lgx.wechatfloatdemo.weghit;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.TypeEvaluator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.graphics.Xfermode;
import android.support.v7.widget.AppCompatImageView;
import android.util.AttributeSet;

/**
 * Created by Harry on 2018/8/9.
 * desc:
 */

public class ScaleCircleImageView extends AppCompatImageView {
    private RectF mRectF;
    private ScaleCircleAnimation scaleCircleAnimation;
    private Paint mPaint;
    private ScaleCircleListener listener;
    Bitmap src;
    private Xfermode xfermode;

    public ScaleCircleImageView(Context context) {
        super(context);
        setWillNotDraw(false);
    }

    public ScaleCircleImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setWillNotDraw(false);
    }

    public ScaleCircleImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setWillNotDraw(false);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (mPaint == null) {
            mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
            mPaint.setDither(true);
        }
        if (mRectF == null) {
            mRectF = new RectF();
        }
        if (xfermode == null) {
            xfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
        }
        if (scaleCircleAnimation != null) {
            int left = scaleCircleAnimation.getLeftX();
            int top = scaleCircleAnimation.getTopY();
            int right = scaleCircleAnimation.getRightX();
            int bottom = scaleCircleAnimation.getBottomY();
            float radius = scaleCircleAnimation.getRadius();
            mRectF.set(left, top, right, bottom);
//            canvas.clipRect(mRectF);
            canvas.drawRoundRect(mRectF, radius, radius, mPaint);
            //設定Xfermode
            mPaint.setXfermode(xfermode);
            //源圖
            canvas.drawBitmap(src, 0, 0, mPaint);
            //還原Xfermode
            mPaint.setXfermode(null);

        }
    }

    private int width;


    public void startAnimation(Bitmap bitmap, int width) {

        if (animationParam == null) {
            throw new IllegalArgumentException("animationParam has  been init!");
        }
        this.width = width;
        src = bitmap;
        ValueAnimator valueAnimator = new ValueAnimator();
        valueAnimator.setObjectValues(new ScaleCircleAnimation(animationParam.fromLeftX, animationParam.fromRightX, animationParam.fromTopY, animationParam.fromBottomY, animationParam.fromRadius),
                new ScaleCircleAnimation(animationParam.toLeftX, animationParam.toRightX, animationParam.toTopY, animationParam.toBottomY, animationParam.toRadius));
        valueAnimator.setEvaluator(new TypeEvaluator<ScaleCircleAnimation>() {
            @Override
            public ScaleCircleAnimation evaluate(float fraction, ScaleCircleAnimation startValue, ScaleCircleAnimation endValue) {
                int leftX = (int) (startValue.getLeftX() + fraction * (endValue.getLeftX() - startValue.getLeftX()));
                int topY = (int) (startValue.getTopY() + fraction * (endValue.getTopY() - startValue.getTopY()));
                int rightX = (int) (startValue.getRightX() + fraction * (endValue.getRightX() - startValue.getRightX()));
                int bottomY = (int) (startValue.getBottomY() + fraction * (endValue.getBottomY() - startValue.getBottomY()));
                float radius = (startValue.getRadius() + fraction * (endValue.getRadius() - startValue.getRadius()));
                return new ScaleCircleAnimation(leftX, rightX, topY, bottomY, radius);
            }
        });
        valueAnimator.setDuration(500);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                scaleCircleAnimation = (ScaleCircleAnimation) animation.getAnimatedValue();
                invalidate();
            }
        });
        valueAnimator.start();
        valueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                if (listener != null) {
                    listener.onAnimationEnd();
                }
            }
        });
    }

    private  AnimationParam animationParam;

    public  AnimationParam createAnmiationParam() {
        return animationParam = new AnimationParam();
    }


    public  class AnimationParam {
        int fromLeftX;
        int fromRightX;
        int toLeftX;
        int toRightX;
        int fromTopY;
        int fromBottomY;
        int toTopY;
        int toBottomY;
        int fromRadius;
        int toRadius;


        public AnimationParam setFromLeftX(int fromLeftX) {
            this.fromLeftX = fromLeftX;
            return this;
        }

        public AnimationParam setFromRightX(int fromRightX) {
            this.fromRightX = fromRightX;
            return this;
        }

        public AnimationParam setToLeftX(int toLeftX) {
            this.toLeftX = toLeftX;
            return this;
        }

        public AnimationParam setToRightX(int toRightX) {
            this.toRightX = toRightX;
            return this;
        }

        public AnimationParam setFromTopY(int fromTopY) {
            this.fromTopY = fromTopY;
            return this;
        }

        public AnimationParam setFromBottomY(int fromBottomY) {
            this.fromBottomY = fromBottomY;
            return this;
        }

        public AnimationParam setToTopY(int toTopY) {
            this.toTopY = toTopY;
            return this;
        }

        public AnimationParam setToBottomY(int toBottomY) {
            this.toBottomY = toBottomY;
            return this;
        }

        public AnimationParam setFromRadius(int fromRadius) {
            this.fromRadius = fromRadius;
            return this;
        }

        public AnimationParam setToRadius(int toRadius) {
            this.toRadius = toRadius;
            return this;
        }
    }

    public void setScaleCircleListener(ScaleCircleListener listener) {
        this.listener = listener;

    }

    public interface ScaleCircleListener {
        void onAnimationEnd();
    }
}


動畫主要是以下的程式碼:

  mRectF.set(left, top, right, bottom);
  canvas.drawRoundRect(mRectF, radius, radius, mPaint);
   //設定Xfermode
  mPaint.setXfermode(xfermode);

通過PorterDuffXfermode通過以下形式,來疊放兩個圖片和被切的檢視

  xfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);

其中,圖片的獲取是以下方式:

  View view = parent;
        Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        view.draw(canvas);

專案中,因為專案中程式碼太多,還有很多內容沒有在部落格中寫出來,如果大家有問題,可以在文末說出來,謝謝。