Android模仿微信浮窗功能的效果實現
阿新 • • 發佈:2019-01-26
最近研究了微信懸浮窗的效果實現,寫此文章記錄一下,後面有我的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);
專案中,因為專案中程式碼太多,還有很多內容沒有在部落格中寫出來,如果大家有問題,可以在文末說出來,謝謝。