Android中自定义悬浮窗

有时候,我们可能会需要制作一个始终悬浮的窗口显示一些关键信息。它独立于我们的页面,可以在不妨碍用户操作的情况下显示信息。这里我们就学习一下悬浮窗的做法。

1.WindowManager的常用方法

  • getDefaultDisplay:获取默认的显示屏信息。通常可用该方法获取屏幕分辨率。
  • addView:往窗口添加视图。第二个参数为WindowManager.LayoutParams对象。
  • updateViewLayout:更新指定视图的布局参数。第二个参数为WindowManager.LayoutParams对象。
  • removeView:从窗口移除指定视图。

下面是窗口布局参数WindowManager.LayoutParams的常用属性

  • alpha:窗口的透明度,取值为0.0到1.0。0.0表示全透明,1.0表示不透明。
  • gravity:内部视图的对齐方式。取值同View的setGravity方法。
  • x和y:分别表示窗口左上角的X坐标和Y坐标。
  • width和height:分别表示窗口的宽度和高度。
  • format:窗口的像素点格式。取值见PixelFormat类中的常量定义,一般取值PixelFormat.RGBA_8888。
  • type:窗口的显示类型,常用的显示类型见下表。
WindowManager类的窗口显示类型说明
TYPE_SYSTEM_ALERT系统警告提示
TYPE_SYSTEM_ERROR系统错误提示
TYPE_SYSTEM_OVERLAY页面顶层提示
TYPE_SYSTEM_DIALOG系统对话框
TYPE_STATUS_BAR状态栏
TYPE_TOAST短暂通知Toast
  • flags:窗口的行为准则,对于悬浮窗来说,一般设置为FLAG_NOT_FOCUSABLE。常用的窗口标志位说明见下表。
WindowManager类的窗口标志位说明
FLAG_NOT_FOCUSABLE不能抢占焦点,即不接受任何按键或按钮事件
FLAG_NOT_TOUCHABLE不接受触摸屏事件。悬浮窗一般不设置该标志,因为一旦设置该标志,将无法拖动悬浮窗。
FLAG_NOT_TOUCH_MODAL当窗口允许获得焦点时(即没有设置FLAG_NOT_FOCUSABLE标志),仍然将窗口之外的按键事件发送给后面的窗口处理。否则它将独占所有的按键事件,而不管它们是不是发生在窗口范围之内。
FLAG_LAYOUT_IN_SCREEN允许窗口占满整个屏幕
FLAG_LAYOUT_NO_LIMITS允许窗口扩展到屏幕之外
FLAG_WATCH_OUTSIDE_TOUCH如果设置了FLAG_NOT_TOUCH_MODAL标志,则当按键动作发生在窗口之外时,将接收到一个MotionEvent.ACTION_OUTSIDE事件

2.自定义悬浮窗与对话框区别

  • 悬浮窗是可以拖动的,对话框则不可拖动。
  • 悬浮窗不妨碍用户触摸窗外的区域,对话框则不让用户操作框外的控件。
  • 悬浮窗独立于Activity页面,即当页面退出后,悬浮窗仍停留在屏幕上;而对话框与Activity页面是共存关系,一旦页面退出则对话框也消失了。

3.悬浮窗制作步骤

  • 在AndroidManifest.xml中声明系统窗口权限,即增加下面这句:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
  • 在自定义悬浮窗控件中,要设置触摸监听器,并根据用户的手势滑动来相应调整窗口位置,以实现悬浮窗的拖动功能。
  • 合理设置悬浮窗的窗口参数,主要是把窗口参数的显示类型设置为TYPE_SYSTEM_ALERT或者TYPE_SYSTEM_ERROR,另外还要设置标志位为FLAG_NOT_FOCUSABLE。
  • 在构造悬浮窗实例时,要传入Application的上下文Context,这是为了保证即使退出Activity,也不会关闭悬浮窗。因为Application对象在App运行过程中是始终存在着的,而Activity对象只在打开页面时有效,一旦退出页面则Activity的上下文就立刻回收(这会导致依赖于该上下文的悬浮窗也一块被回收了)。

4.代码示例

首先需要一个悬浮窗类

FloatWindow.java

@SuppressLint("ClickableViewAccessibility")
public class FloatWindow extends View {
    private final static String TAG = "FloatWindow";
    private Context mContext; // 声明一个上下文对象
    private WindowManager wm; // 声明一个窗口管理器对象
    private static WindowManager.LayoutParams wmParams;
    public View mContentView; // 声明一个内容视图对象
    private float mScreenX, mScreenY; // 触摸点在屏幕上的横纵坐标
    private float mLastX, mLastY; // 上次触摸点的横纵坐标
    private float mDownX, mDownY; // 按下点的横纵坐标
    private boolean isShowing = false; // 是否正在显示

    public FloatWindow(Context context) {
        super(context);
        // 从系统服务中获取窗口管理器,后续将通过该管理器添加悬浮窗
        wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        if (wmParams == null) {
            wmParams = new WindowManager.LayoutParams();
        }
        mContext = context;
    }

    // 设置悬浮窗的内容布局
    public void setLayout(int layoutId) {
        // 从指定资源编号的布局文件中获取内容视图对象
        mContentView = LayoutInflater.from(mContext).inflate(layoutId, null);
        // 接管悬浮窗的触摸事件,使之即可随手势拖动,又可处理点击动作
        mContentView.setOnTouchListener(new OnTouchListener() {
            // 在发生触摸事件时触发
            public boolean onTouch(View v, MotionEvent event) {
                mScreenX = event.getRawX();
                mScreenY = event.getRawY();
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN: // 手指按下
                        mDownX = mScreenX;
                        mDownY = mScreenY;
                        break;
                    case MotionEvent.ACTION_MOVE: // 手指移动
                        updateViewPosition(); // 更新视图的位置
                        break;
                    case MotionEvent.ACTION_UP: // 手指松开
                        updateViewPosition(); // 更新视图的位置
                        // 响应悬浮窗的点击事件
                        if (Math.abs(mScreenX - mDownX) < 3
                                && Math.abs(mScreenY - mDownY) < 3) {
                            if (mListener != null) {
                                mListener.onFloatClick(v);
                            }
                        }
                        break;
                }
                mLastX = mScreenX;
                mLastY = mScreenY;
                return true;
            }
        });
    }

    // 更新悬浮窗的视图位置
    private void updateViewPosition() {
        // 此处不能直接转为整型,因为小数部分会被截掉,重复多次后就会造成偏移越来越大
        wmParams.x = Math.round(wmParams.x + mScreenX - mLastX);
        wmParams.y = Math.round(wmParams.y + mScreenY - mLastY);
        // 通过窗口管理器更新内容视图的布局参数
        wm.updateViewLayout(mContentView, wmParams);
    }

    // 显示悬浮窗
    public void show() {
        if (mContentView != null) {
            // 设置为TYPE_SYSTEM_ALERT类型,才能悬浮在其它页面之上
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
                // 注意TYPE_SYSTEM_ALERT从Android8.0开始被舍弃了
                wmParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
            } else {
                // 从Android8.0开始悬浮窗要使用TYPE_APPLICATION_OVERLAY
                wmParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
            }
            wmParams.format = PixelFormat.RGBA_8888;
            wmParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
            wmParams.alpha = 1.0f; // 1.0为完全不透明,0.0为完全透明
            // 对齐方式为靠左且靠上,因此悬浮窗的初始位置在屏幕的左上角
            wmParams.gravity = Gravity.LEFT | Gravity.TOP;
            wmParams.x = 0;
            wmParams.y = 0;
            // 设置悬浮窗的宽度和高度为自适应
            wmParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
            wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
            // 添加自定义的窗口布局,然后屏幕上就能看到悬浮窗了
            wm.addView(mContentView, wmParams);
            isShowing = true;
        }
    }

    // 关闭悬浮窗
    public void close() {
        if (mContentView != null) {
            // 移除自定义的窗口布局
            wm.removeView(mContentView);
            isShowing = false;
        }
    }

    // 判断悬浮窗是否打开
    public boolean isShow() {
        return isShowing;
    }

    private FloatClickListener mListener; // 声明一个悬浮窗的点击监听器对象
    // 设置悬浮窗的点击监听器
    public void setOnFloatListener(FloatClickListener listener) {
        mListener = listener;
    }

    // 定义一个悬浮窗的点击监听器接口,用于触发点击行为
    public interface FloatClickListener {
        void onFloatClick(View v);
    }

}
FlowUtil.java
public class FlowUtil {
    private final static float divUnit = 1024.00f;
    private final static int cmpUnit = 1000;
    public final static String ZEROB = "0B";

    // 格式化流量字符串
    public static String BToShowString(long flowB, int decimal) {
        NumberFormat ddf1 = NumberFormat.getNumberInstance();
        ddf1.setMaximumFractionDigits(decimal);
        if (flowB <= 0) {
            return ZEROB;
        }
        if (flowB < cmpUnit) {
            return flowB + "B";
        }
        if (flowB / cmpUnit < cmpUnit) {
            double res = (double) flowB / divUnit;
            return ddf1.format(res) + "K";
        }
        if (flowB / cmpUnit / cmpUnit < cmpUnit) {
            double res = (double) flowB / divUnit;
            res /= divUnit;
            return ddf1.format(res) + "M";
        }
        double res = (double) flowB / divUnit;
        res /= divUnit;
        res /= divUnit;
        return ddf1.format(res) + "G";
    }

}
PermissionUtil.java
public class PermissionUtil {

	public static void gotoPermission(Context context) {
		String brand = Build.BRAND;//手机厂商
		if (TextUtils.equals(brand.toLowerCase(), "redmi") || TextUtils.equals(brand.toLowerCase(), "xiaomi")) {
			PermissionUtil.gotoMiuiPermission(context);//小米
		} else if (TextUtils.equals(brand.toLowerCase(), "meizu")) {
			PermissionUtil.gotoMeizuPermission(context);
		} else if (TextUtils.equals(brand.toLowerCase(), "huawei") || TextUtils.equals(brand.toLowerCase(), "honor")) {
			PermissionUtil.gotoHuaweiPermission(context);
		} else {
			context.startActivity(PermissionUtil.getAppDetailSettingIntent(context));
		}
	}


	/**
	 * 跳转到miui的权限管理页面
	 */
	private static void gotoMiuiPermission(Context context) {
		try { // MIUI 8
			Intent localIntent = new Intent("miui.intent.action.APP_PERM_EDITOR");
			localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.PermissionsEditorActivity");
			localIntent.putExtra("extra_pkgname", context.getPackageName());
			context.startActivity(localIntent);
		} catch (Exception e) {
			try { // MIUI 5/6/7
				Intent localIntent = new Intent("miui.intent.action.APP_PERM_EDITOR");
				localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.AppPermissionsEditorActivity");
				localIntent.putExtra("extra_pkgname", context.getPackageName());
				context.startActivity(localIntent);
			} catch (Exception e1) { // 否则跳转到应用详情
				context.startActivity(getAppDetailSettingIntent(context));
			}
		}
	}

	/**
	 * 跳转到魅族的权限管理系统
	 */
	private static void gotoMeizuPermission(Context context) {
		try {
			Intent intent = new Intent("com.meizu.safe.security.SHOW_APPSEC");
			intent.addCategory(Intent.CATEGORY_DEFAULT);
			intent.putExtra("packageName", BuildConfig.APPLICATION_ID);
			context.startActivity(intent);
		} catch (Exception e) {
			e.printStackTrace();
			context.startActivity(getAppDetailSettingIntent(context));
		}
	}

	/**
	 * 华为的权限管理页面
	 */
	private static void gotoHuaweiPermission(Context context) {
		try {
			Intent intent = new Intent();
			intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
			ComponentName comp = new ComponentName("com.huawei.systemmanager", "com.huawei.permissionmanager.ui.MainActivity");//华为权限管理
			intent.setComponent(comp);
			context.startActivity(intent);
		} catch (Exception e) {
			e.printStackTrace();
			context.startActivity(getAppDetailSettingIntent(context));
		}

	}

	/**
	 * 获取应用详情页面intent(如果找不到要跳转的界面,也可以先把用户引导到系统设置页面)
	 */
	private static Intent getAppDetailSettingIntent(Context context) {
		Intent localIntent = new Intent();
		localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
		localIntent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS");
		localIntent.setData(Uri.fromParts("package", context.getPackageName(), null));

		return localIntent;
	}
}
TrafficService.java
@SuppressLint("SetTextI18n")
public class TrafficService extends Service {
    private final static String TAG = "TrafficService";
    private FloatWindow mFloatWindow; // 声明一个悬浮窗对象
    private TextView tv_traffic;
    public static int OPEN = 0; // 打开悬浮窗
    public static int CLOSE = 1; // 关闭悬浮窗
    private long curRx; // 当前接收的流量
    private long curTx; // 当前发送的流量
    private final int delayTime = 2000; // 刷新的间隔时间

    // 创建一个处理器对象
    private Handler mHandler = new Handler();
    // 定义一个流量刷新任务
    private Runnable mRefresh = new Runnable() {
        public void run() {
            if (mFloatWindow != null && mFloatWindow.isShow() &&
                    (TrafficStats.getTotalRxBytes() > curRx || TrafficStats.getTotalTxBytes() > curTx)) {
                // 平均一下接收的流量和发送的流量
                long flow = ((TrafficStats.getTotalRxBytes() - curRx) + (TrafficStats
                        .getTotalTxBytes() - curTx)) / 2;
                String desc = String.format("即时流量: %s/S", FlowUtil.BToShowString(flow, 0));
                tv_traffic.setText(desc);
                // 获取接收流量的总字节数
                curRx = TrafficStats.getTotalRxBytes();
                // 获取发送流量的总字节数
                curTx = TrafficStats.getTotalTxBytes();
            }
            // 延迟若干秒后再次启动流量刷新任务
            mHandler.postDelayed(this, delayTime);
        }
    };

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        if (mFloatWindow == null) {
            // 创建一个新的悬浮窗
            mFloatWindow = new FloatWindow(MyApplication.getInstance());
            // 设置悬浮窗的布局内容
            mFloatWindow.setLayout(R.layout.float_traffic);
            // 从布局文件中获取展示即时流量的文本视图
            tv_traffic = mFloatWindow.mContentView.findViewById(R.id.tv_traffic);
        }
        // 获取接收流量的总字节数
        curRx = TrafficStats.getTotalRxBytes();
        // 获取发送流量的总字节数
        curTx = TrafficStats.getTotalTxBytes();
        // 立即启动流量刷新任务
        mHandler.post(mRefresh);
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null) {
            // 从意图中解包获得操作类型
            int type = intent.getIntExtra("type", OPEN);
            if (type == OPEN) { // 打开
                if (mFloatWindow != null && !mFloatWindow.isShow()) {
                    tv_traffic.setText("即时流量: 0B/S");
                    mFloatWindow.show(); // 显示悬浮窗
                }
            } else if (type == CLOSE) { // 关闭
                if (mFloatWindow != null && mFloatWindow.isShow()) {
                    mFloatWindow.close(); // 关闭悬浮窗
                }
                stopSelf(); // 停止自身服务
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        // 移除股指刷新任务
        mHandler.removeCallbacks(mRefresh);
    }

}

float_traffic.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical" >

    <TextView
        android:id="@+id/tv_traffic"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#99000000"
        android:textColor="#FFFFFF"
        android:textSize="15sp" />
    
</LinearLayout>

MainActivity.java

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private TextView tv_open;
    private TextView tv_close;
    private boolean backFromSetting;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tv_open = findViewById(R.id.tv_open);
        tv_close = findViewById(R.id.tv_close);
        tv_open.setOnClickListener(this);
        tv_close.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        Intent intent = null;
        switch (v.getId()){
            case R.id.tv_open:
                // 下面携带打开类型启动流量服务
                if (checkAlertWindowsPermission(this)){
                    //已经允许
                    intent = new Intent(this, TrafficService.class);
                    intent.putExtra("type", TrafficService.OPEN);
                    startService(intent);
                }else{
                    //已经禁止
                    new AlertDialog.Builder(this).setTitle("需要打开悬浮窗权限,是否现在前往")
                            .setPositiveButton("确定", new DialogInterface.OnClickListener() {
                                @Override
                                public void onClick(DialogInterface dialog, int which) {
                                    //点击确定触发的事件
                                    backFromSetting = true;
                                    PermissionUtil.gotoPermission(MainActivity.this);
                                }
                            })
                            .setNegativeButton("返回", new DialogInterface.OnClickListener() {

                                @Override
                                public void onClick(DialogInterface dialog, int which) {
                                    //点击取消触发的事件
                                }
                            }).show();
                }
                break;
            case R.id.tv_close:
                // 下面携带关闭类型启动流量服务
                intent = new Intent(this, TrafficService.class);
                intent.putExtra("type", TrafficService.CLOSE);
                startService(intent);
                break;
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (backFromSetting){
            backFromSetting = false;
            if (checkAlertWindowsPermission(this)){
                //已经允许
                Intent intent = new Intent(this, TrafficService.class);
                intent.putExtra("type", TrafficService.OPEN);
                startService(intent);
            }else{
                //已经禁止
                ToastUtil.toastWord(this,"您未允许悬浮窗权限,无法打开悬浮窗");
            }
        }
    }

    /**
     * 判断 悬浮窗口权限是否打开
     * @param context
     * @return true 允许  false禁止
     */
    public boolean checkAlertWindowsPermission(Context context) {
        try {
            Object object = context.getSystemService(Context.APP_OPS_SERVICE);
            if (object == null) {
                return false;
            }
            Class localClass = object.getClass();
            Class[] arrayOfClass = new Class[3];
            arrayOfClass[0] = Integer.TYPE;
            arrayOfClass[1] = Integer.TYPE;
            arrayOfClass[2] = String.class;
            Method method = localClass.getMethod("checkOp", arrayOfClass);
            if (method == null) {
                return false;
            }
            Object[] arrayOfObject1 = new Object[3];
            arrayOfObject1[0] = 24;
            arrayOfObject1[1] = Binder.getCallingUid();
            arrayOfObject1[2] = context.getPackageName();
            int m = ((Integer) method.invoke(object, arrayOfObject1));
            return m == AppOpsManager.MODE_ALLOWED;
        } catch (Exception ex) {

        }
        return false;
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tv_open"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:text="打开流量"
        android:gravity="center"
        android:background="@android:color/holo_green_light"/>

    <TextView
        android:id="@+id/tv_close"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:text="关闭流量"
        android:layout_marginTop="100dp"
        android:gravity="center"
        android:background="@android:color/holo_green_light"/>

</LinearLayout>

添加权限

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

清单文件AndroidManifest.xml的Application节点下配置Service

<service android:name=".TrafficService"/>

这样就完成了一个悬浮窗的制作

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值