应公司需求,最近需要在项目中添加悬浮窗功能,需求是只在首页显示(这个需求是后来提出的,要是早知道就可以直接在首页布局中添加一个view,然后通过手势监听控制以及view的gone和visible即可实现,所以这篇文章不对上述方式进行讲解,而是采用WindowManager进行开发)、可拖拽、可点击可动态配置悬浮窗显示图片以及点击事件……原本以为这只是一个小小的需求,可是在提交测试之后发现了不少兼容性问题,下面简要的谈一下开发中遇到的问题,以及我所对应的解决方式。一、定义悬浮窗控件FloatWindow首先我们需要自定义一个悬浮窗控件,以及它的显示样式,我将其定义为FloatWindow,网上一搜能收到一大把类似的文档,此处不多做讲解,直接上代码,仅作为参考
import java.lang.reflect.Field;
import com.bumptech.glide.Glide;
import com.crfchina.market.R;
import android.content.Context;
import android.os.Build;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.LinearLayout;
public class FloatWindowView extends LinearLayout {
/**
* 记录悬浮窗的宽度
*/
public static int viewWidth;
/**
* 记录悬浮窗的高度
*/
public static int viewHeight;
/**
* 记录系统状态栏的高度
*/
private static int statusBarHeight;
/**
* 用于更新悬浮窗的位置
*/
private WindowManager windowManager;
/**
* 悬浮窗的参数
*/
private WindowManager.LayoutParams mParams;
/**
* 记录当前手指位置在屏幕上的横坐标值
*/
private float xInScreen;
/**
* 记录当前手指位置在屏幕上的纵坐标值
*/
private float yInScreen;
/**
* 记录手指按下时在屏幕上的横坐标的值
*/
private float xDownInScreen;
/**
* 记录手指按下时在屏幕上的纵坐标的值
*/
private float yDownInScreen;
/**
* 记录手指按下时在悬浮窗的View上的横坐标的值
*/
private float xInView;
/**
* 记录手指按下时在悬浮窗的View上的纵坐标的值
*/
private float yInView;
/**
* 用于判断是否可点击
*/
private boolean canClickable = true;
private Context context;
private String imgUrl;
public void setCanClickable(boolean clickable){
canClickable = clickable;
}
private OnSuspensionViewClickListener onSuspensionViewClickListener;
public interface OnSuspensionViewClickListener{
void onClick();
}
public void setOnSuspensionViewClickListener(OnSuspensionViewClickListener listener){
onSuspensionViewClickListener = listener;
}
public FloatWindowView(Context context, String imgUrl) {
super(context);
this.context = context;
this.imgUrl = imgUrl;
windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
LayoutInflater.from(context).inflate(R.layout.float_window_widget, this);
View view = findViewById(R.id.layout_floatwindow);
viewWidth = view.getLayoutParams().width;
viewHeight = view.getLayoutParams().height;
ImageView imgFloatwindow = (ImageView) findViewById(R.id.img_floatwindow);
Glide.with(context).load(imgUrl).error(null).centerCrop().into(imgFloatwindow);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
String s = Build.MODEL;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 手指按下时记录必要数据,纵坐标的值都需要减去状态栏高度
xInView = event.getX();
yInView = event.getY();
xDownInScreen = event.getRawX();
if (s.equalsIgnoreCase("vivo Y27") ) {
yDownInScreen = event.getRawY();
}else{
yDownInScreen = event.getRawY() - getStatusBarHeight();
}
xInScreen = event.getRawX();
if (s.equalsIgnoreCase("vivo Y27")) {
yInScreen = event.getRawY();
}else{
yInScreen = event.getRawY() - getStatusBarHeight();
}
break;
case MotionEvent.ACTION_MOVE:
xInScreen = event.getRawX();
if (s.equalsIgnoreCase("vivo Y27")) {
yInScreen = event.getRawY();
}else{
yInScreen = event.getRawY() - getStatusBarHeight();
}
// 手指移动的时候更新悬浮窗的位置
updateViewPosition((int) (xInScreen - xInView), (int) (yInScreen - yInView));
break;
case MotionEvent.ACTION_UP:
// 如果手指离开屏幕时xInScreen在xDownInScreen前后10像素的范围内,且yInScreen在yDownInScreen前后10像素的范围内,则视为触发了单击事件。
if ((xDownInScreen-10) <= xInScreen && xInScreen <= (xDownInScreen+10)
&& (yDownInScreen-10) <= yInScreen && yInScreen <= (yDownInScreen+10)) {
// openBigWindow();
// 点击进入web页面
if(canClickable){
doClickWindow();
}
} else {
int screenWidth = windowManager.getDefaultDisplay().getWidth();
int x;
if ((xInScreen - xInView) > (screenWidth - viewWidth) / 2) {
x = screenWidth;
} else {
x = 0;
}
updateViewPosition(x, (int) (yInScreen - yInView));
}
break;
default:
break;
}
return true;
}
/**
* 将悬浮窗的参数传入,用于更新悬浮窗的位置。
*
* @param params
* 悬浮窗的参数
*/
public void setParams(WindowManager.LayoutParams params) {
mParams = params;
}
/**
* 更新悬浮窗在屏幕中的位置。
*/
private void updateViewPosition(int x, int y) {
mParams.x = x;
mParams.y = y;
windowManager.updateViewLayout(this, mParams);
}
private void doClickWindow() {
onSuspensionViewClickListener.onClick();
}
/**
* 用于获取状态栏的高度。
*
* @return 返回状态栏高度的像素值。
*/
private int getStatusBarHeight() {
if (statusBarHeight == 0) {
try {
Class<?> c = Class.forName("com.android.internal.R$dimen");
Object o = c.newInstance();
Field field = c.getField("status_bar_height");
int x = (Integer) field.get(o);
statusBarHeight = getResources().getDimensionPixelSize(x);
} catch (Exception e) {
e.printStackTrace();
}
}
return statusBarHeight;
}
}
其中值得注意的是: 1、为了确保能够实现动态配置点击事件,我定义了一个点击监听;
2、为了区别点击事件和拖拽事件,我在onTouchEvent中的MotionEvent.ACTION_UP事件中坐了如下判断
if ((xDownInScreen-10) <= xInScreen && xInScreen <= (xDownInScreen+10)
&& (yDownInScreen-10) <= yInScreen && yInScreen <= (yDownInScreen+10)) {
// openBigWindow();
// 点击进入web页面
if(canClickable){
doClickWindow();
}
} else {
int screenWidth = windowManager.getDefaultDisplay().getWidth();
int x;
if ((xInScreen - xInView) > (screenWidth - viewWidth) / 2) {
x = screenWidth;
} else {
x = 0;
}
updateViewPosition(x, (int) (yInScreen - yInView));
}
3、点击事件的canClickable是为了避免悬浮窗可多次点击,这样会导致点击事件执行多次
4、动态加载悬浮窗图片我这里采用的是用Glide加载图片链接,链接由调用者配置
5、测试时发现在vivo Y27机型上会出现通知栏获取不到的情况,这就导致了在松手的那一刻悬浮窗会上移一个状态栏的高度所以我单独对其进行了判断,若还有其他机型出现此类情况,可在判断中加上
对应的布局文件:
<?xml version="1.0" encoding="UTF-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/layout_floatwindow"
android:layout_width="55dip"
android:layout_height="55dip"
android:background="@android:color/transparent"
android:orientation="horizontal" >
<ImageView
android:id="@+id/img_floatwindow"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:gravity="center"
android:scaleType="centerInside"
android:textColor="#ffffff" />
</LinearLayout>
二、定义悬浮窗控制器MyWindowManager
定义MyWindowManager是对悬浮窗的一个封装,方便调用者实现调用,同时也对悬浮窗的显示方式进行设置,值得注意的是,悬浮窗在Android6.0之后会有个中权限的限制,而查阅资料后可以知道,当悬浮窗的type参数设置成TYPE_TOAST时,可以无需权限即可实现悬浮窗,但是,在魅族手机和小米上,这个就不是那么简单了,遇到的问题稍后会补上,现在上代码,代码如下:
import java.lang.reflect.Method;
import com.bumptech.glide.Glide;
import com.crfchina.market.R;
import com.crfchina.market.cycleloans.widget.FloatWindowView.OnSuspensionViewClickListener;
import android.annotation.TargetApi;
import android.app.ActivityManager;
import android.app.AppOpsManager;
import android.content.Context;
import android.graphics.PixelFormat;
import android.os.Binder;
import android.os.Build;
import android.view.Gravity;
import android.view.WindowManager;
import android.view.WindowManager.LayoutParams;
import android.widget.ImageView;
public class MyWindowManager {
/**
* 悬浮窗View的实例
*/
private static FloatWindowView floatWindow;
/**
* 悬浮窗View的参数
*/
private static WindowManager.LayoutParams windowParams;
/**
* 用于控制在屏幕上添加或移除悬浮窗
*/
private static WindowManager mWindowManager;
/**
* 用于获取手机可用内存
*/
private static ActivityManager mActivityManager;
private static OnSuspensionViewClickListener myListener;
/**
* 这里是给调用者设置的一个点击监听的代理
* @param listener
*/
public static void setOnSuspensionViewClickListlistenerener(OnSuspensionViewClickListener listener){
myListener = listener;
}
/**
* 创建一个悬浮窗。初始位置为屏幕的右部中间位置。
*
* @param context
* 必须为应用程序的Context.
* @param imgUrl 悬浮窗图片url地址
*/
public static void createWindow(Context context, String imgUrl, boolean canClickable) {
WindowManager windowManager = getWindowManager(context);
int screenWidth = windowManager.getDefaultDisplay().getWidth();
int screenHeight = windowManager.getDefaultDisplay().getHeight();
if (floatWindow == null) {
floatWindow = new FloatWindowView(context,imgUrl);
floatWindow.setOnSuspensionViewClickListener(myListener);
floatWindow.setCanClickable(canClickable);
if (windowParams == null) {
windowParams = new LayoutParams();
// windowParams.type = LayoutParams.TYPE_PHONE;
if("Xiaomi".equals(Build.MANUFACTURER) || Build.VERSION.SDK_INT < 21){
windowParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
| WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY;
}else{
windowParams.type = LayoutParams.TYPE_TOAST;
}
windowParams.format = PixelFormat.RGBA_8888;
windowParams.flags = LayoutParams.FLAG_NOT_TOUCH_MODAL
| LayoutParams.FLAG_NOT_FOCUSABLE;
windowParams.gravity = Gravity.LEFT | Gravity.TOP;
windowParams.width = FloatWindowView.viewWidth;
windowParams.height = FloatWindowView.viewHeight;
windowParams.x = screenWidth;
windowParams.y = screenHeight / 2;
}
floatWindow.setParams(windowParams);
windowManager.addView(floatWindow, windowParams);
}
}
/**
* 将悬浮窗从屏幕上移除。
*
* @param context
* 必须为应用程序的Context.
* @param can
*/
public static void removeWindow(Context context, boolean canClickable) {
if (floatWindow != null) {
floatWindow.setCanClickable(canClickable);
WindowManager windowManager = getWindowManager(context);
windowManager.removeView(floatWindow);
floatWindow = null;
}
}
/**
* 更新悬浮窗的显示图片
* @param context
* @param imgUrl
* @param canClickable
*/
public static void updateFloatWindow(Context context,String imgUrl, boolean canClickable) {
if (floatWindow != null) {
floatWindow.setCanClickable(canClickable);
ImageView imgFloatwindow = (ImageView) floatWindow.findViewById(R.id.img_floatwindow);
Glide.with(context).load(imgUrl).error(null).centerCrop().into(imgFloatwindow);
}
}
/**
* 判断悬浮窗是否显示
*
* @return 有悬浮窗显示在桌面上返回true,没有的话返回false。
*/
public static boolean isWindowShowing() {
return floatWindow != null;
}
/**
* 如果WindowManager还未创建,则创建一个新的WindowManager返回。否则返回当前已创建的WindowManager。
*
* @param context
* 必须为应用程序的Context.
* @return WindowManager的实例,用于控制在屏幕上添加或移除悬浮窗。
*/
private static WindowManager getWindowManager(Context context) {
if (mWindowManager == null) {
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
}
return mWindowManager;
}
/**
* 如果ActivityManager还未创建,则创建一个新的ActivityManager返回。否则返回当前已创建的ActivityManager。
*
* @param context
* 可传入应用程序上下文。
* @return ActivityManager的实例
*/
private static ActivityManager getActivityManager(Context context) {
if (mActivityManager == null) {
mActivityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
}
return mActivityManager;
}
/**
* 判断悬浮窗权限
*
* @param context
* @return
*/
@TargetApi(Build.VERSION_CODES.KITKAT)
public static boolean isFloatWindowOpAllowed(Context context) {
final int version = Build.VERSION.SDK_INT;
if (version >= 19) {
return checkOp(context, 24); // AppOpsManager.OP_SYSTEM_ALERT_WINDOW
} else {
if ((context.getApplicationInfo().flags & 1 << 27) == 1 << 27) {
return true;
} else {
return false;
}
}
}
@TargetApi(Build.VERSION_CODES.KITKAT)
public static boolean checkOp(Context context, int op) {
final int version = Build.VERSION.SDK_INT;
if (version >= 19) {
AppOpsManager manager = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
try {
Class<?> spClazz = Class.forName(manager.getClass().getName());
Method method = manager.getClass().getDeclaredMethod("checkOp", int.class, int.class, String.class);
int property = (Integer) method.invoke(manager, op,
Binder.getCallingUid(), context.getPackageName());
if (AppOpsManager.MODE_ALLOWED == property) {
return true;
} else {
return false;
}
} catch (Exception e) {
e.printStackTrace();
}
} else {
}
return false;
}
}
眼神尖的同学可以看到我在这里投机取巧了一个方法:
if("Xiaomi".equals(Build.MANUFACTURER) || Build.VERSION.SDK_INT < 21){
windowParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
| WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY;
}else{
windowParams.type = LayoutParams.TYPE_TOAST;
}
这里是为了解决测试时出现的一个问题,
不知道为什么项目在开发出来之后,大部分手机上能正常显示,可是在小米手机上却显示不了或者是出现之后又会消失,还有就是在21版本之下虽然能够正常显示悬浮窗,但是悬浮窗不可移动不可点击(我目前判断是在21版本之下,具体原因没有去探究),所以我单独对小米手机和对版本号在21以下的手机采用了不同的type,而采用非
TYPE_TOAST这个type的时候,如果在6.0之后就需要配置权限了,所以在调用时,还需要对小米手机进行授权设置,而版本号小于21的就不用判断权限了,一般会直接显示出来。
三、调用方式
下面重点讲一下调用方式(因为之前是在service中去控制的,但是想到只在首页显示,所以都搬到首页代码中来了)
if (susToastInfo != null) {
// 显示悬浮按钮
MyWindowManager.setOnSuspensionViewClickListlistenerener(new OnSuspensionViewClickListener() {
@Override
public void onClick() {
Intent intent = new Intent(context, HomeActivity.class);
intent.putExtra("web_url", susToastInfo.getTarget());
startActivity(intent);
overridePendingTransition(R.anim.in_from_right, R.anim.out_to_left);
showFloatWindow = false;
showSuspensionView();
}
});
// 开启悬浮框前先请求权限
if ("Xiaomi".equals(Build.MANUFACTURER) || "Meizu".equals(Build.MANUFACTURER)) {// 小米手机
requestPermission();
} else{
showSuspensionView();
}
}
/**
* 请求用户给予悬浮窗的权限
*/
public void requestPermission() {
if(!MyWindowManager.isFloatWindowOpAllowed(this)){//已经开启
showNoFloatWindowDialog(HomeActivity.this, "悬浮窗", permissionDesc, "取消", "去设置");
} else{
showSuspensionView();
}
}
/** * 打开权限设置界面 ( 此处适配小米和魅族) */ public void openSetting() { try { Intent localIntent = new Intent( "miui.intent.action.APP_PERM_EDITOR"); localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.AppPermissionsEditorActivity"); localIntent.putExtra("extra_pkgname", getPackageName()); startActivityForResult(localIntent,100); } catch (ActivityNotFoundException localActivityNotFoundException) { Intent intent1 = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", getPackageName(), null); intent1.setData(uri); startActivityForResult(intent1,100); } }
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if(requestCode == 100){
// 悬浮窗权限回调
if(MyWindowManager.isFloatWindowOpAllowed(this)){//已经开启
showSuspensionView();
}else {
// Toast.makeText(this,"开启悬浮窗失败",Toast.LENGTH_SHORT).show();
}
}
}
/**
* 用于在线程中创建或移除悬浮窗。
*/
private Handler handler = new Handler();
/**
* 显示悬浮窗
*
* @param info
*/
private void showSuspensionView() {
if (susToastInfo.getUrl() != null && showFloatWindow) {
// 如果当前悬浮窗没有显示,则显示悬浮窗;否则更新悬浮窗;
if (!MyWindowManager.isWindowShowing()) {
handler.post(new Runnable() {
@Override
public void run() {
MyWindowManager.createWindow(context, susToastInfo.getUrl(), showFloatWindow);
}
});
} else {
handler.post(new Runnable() {
@Override
public void run() {
MyWindowManager.updateFloatWindow(getApplicationContext(), susToastInfo.getUrl(), showFloatWindow);
}
});
}
} else {
handler.post(new Runnable() {
@Override
public void run() {
if (MyWindowManager.isWindowShowing())
MyWindowManager.removeWindow(getApplicationContext(), showFloatWindow);
}
});
}
}
注意:1、监听事件必须在显示悬浮窗之前,不然会抛空指针异常;
2、通过handler创建子线程,在子线程中实现对悬浮窗的控制,避免阻塞主线程
3、魅族手机貌似必须设置权限才能显示,并且小米和魅族的设置廯方式不相同,所以要区分,详情可见openSetting
4、其实还有一个方法引导用户进入设置详情页面
if (! Settings.canDrawOverlays(MainActivity.this)) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName()));
startActivityForResult(intent,10);
}
这个方法大部分手机可以直接跳到悬浮窗授权页面,但我的那款魅族机型,死都跳不进去,一点反应都没有,为了兼容,统一用代码中的另一个方法了
5、实现悬浮窗后,vivo手机在下拉通知栏之后,会发现悬浮窗依然在通知栏的上面,为了兼容vivo手机下拉通知栏,重写了一个方法,这个方法就是如果链接信息不为空,当页面获取焦点时显示悬浮窗,当页面失去焦点时隐藏悬浮窗
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if(susToastInfo != null){
if(hasFocus){
//如果焦点获得,进行操作
showFloatWindow = true;
}else{
showFloatWindow = false;
}
showSuspensionView();
}
}