悬浮窗
悬浮窗即可以显示在宿主应用之外的 View 视图,理论上任何 View 都能以悬浮窗形式展示在宿主应用之外甚至锁屏界面,一般在工具类应用中使用的比较多,通过悬浮窗可以很方便的从外界与宿主应用进行交互,例如金山词霸的锁屏单词功能、AirDroid 的录制屏幕菜单、360优化大师的清理悬浮按钮等。
需要了解的
Window
Window 表示一个窗口的概念,在日常开发中直接接触 Window 的机会并不多,但是在特殊时候我们需要在桌面显示一个类似悬浮窗的东西,那么这种效果就需要用到 Window 来实现。Window 是一个抽象类,它的具体实现是 PhoneWindow。创建一个 Window 非常简单,我们通过 WindowManager 即可完成。 Android 中所有视图都是通过 Window 来呈现的,不管是 Activity、Dialog、还是 Toast,它们的视图实际上都是附加在 Window 上的。
WindowManager
应用程序用于与窗口管理器通信的接口,是外界访问 Window 的入口,使用 Context.getSystemService(Context.WINDOW_SERVICE) 获取它的实例。WindowManager提供了addView(View view, ViewGroup.LayoutParams params),removeView(View view),updateViewLayout(View view, ViewGroup.LayoutParams params)三个方法用来向设备屏幕 添加、移除以及更新 一个 view 。
WindowManager.LayoutParams
通过名字就可以看出来 它是WindowManager的一个内部类,专门用来描述 view 的属性 比如大小、透明度 、初始位置、视图层级等。
DisplayMetrics
该对象用来描述关于显示器的一些信息,例如其大小,密度和字体缩放。例如获取屏幕宽度DisplayMetrics.widthPixels 。
最终效果

实现思路
本着实现一个简单的、轻量级的工具类的目的,通过传入一个任意 View 可以将其创建成可自由拖动的悬浮窗
**悬浮一个 View **
首先我们知道 View 能显示在屏幕上其实是间接通过 Window 管理的,那么我们就可以使用 WindowManager 来管理它,让它具备悬浮的属性,下面代码演示了通过 WindowManager 添加 Window 的过程,非常简单
final Button mBtn = new Button(this);
mBtn.setText("悬浮按钮");
mBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(context,"click",Toast.LENGTH_SHORT).show();
}
});
final WindowManager.LayoutParams mLayoutParams = new WindowManager.LayoutParams(WindowManager.LayoutParams.WRAP_CONTENT
,WindowManager.LayoutParams.WRAP_CONTENT,0,0, PixelFormat.TRANSPARENT);
mLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED;
mLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; //view 处于屏幕的相对位置,注意这里必须是 LEFT & TOP,因为 Android 设备屏幕坐标原点在左上角
mLayoutParams.x = 100; //距离屏幕左侧100px
mLayoutParams.y = 300; //距离屏幕上方300px
mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; //指定 Window 类型为 TYPE_SYSTEM_ALERT,属于系统级别,就可以显示在系统屏幕上了
final WindowManager mWindowManager = getWindowManager();
mWindowManager.addView(mBtn,mLayoutParams);
别忘了系统级窗口权限
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
效果如下

使其可以拖动
显然上面的 Button 只是能显示在系统屏幕上而已,并不能拖动,要使其能够拖动就要给它设置一个 View.OnTouchListener 来监听手指在屏幕上滑动的坐标然后根据这个坐标设置其位置,如下实现
mBtn.setOnTouchListener(new View.OnTouchListener() {
//触摸点相对于view左上角的坐标
float downX;
float downY;
@Override
public boolean onTouch(View v, MotionEvent event) {
//获取触摸点相对于屏幕左上角的坐标
float rowX = event.getRawX();
float rowY = event.getRawY() - getStatusBarHeight(context);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
mLayoutParams.x = (int) (rowX - downX); //计算当前触摸点相对于屏幕左上角的 X 轴位置
mLayoutParams.y = (int) (rowY - downY); //计算当前触摸点相对于屏幕左上角的 Y 轴位置
mWindowManager.updateViewLayout(mBtn, mLayoutParams); //更新 Button 到相应位置
break;
case MotionEvent.ACTION_UP:
//actionUp(event);
break;
case MotionEvent.ACTION_OUTSIDE:
//actionOutSide(event);
break;
default:
break;
}
return false;
}
});
解决点击和滑动的事件冲突
现在这个 Button 虽然可以跟着你的手指移动了,但是你会发现当你拖动一段较小距离时会有很大几率响应它的 Click 事件,这显然不能接受,在拖动这个 Button 的整个过程中会依次触发 ACTION_DOWN、ACTION_MOVE、ACTION_MOVE、... 、ACTION_UP,当 ACTION_MOVE 被触发时 ACTION_DOWN 会被释放,之后松开手指触发 ACTION_UP 是不会响应 Click 事件的, Click 事件的响应条件是 ACTION_DOWN + ACTION_UP,所以当我们拖动一个很小的距离时很容易造成 ACTION_DOWN 与 ACTION_UP 的连续触发而响应了 Click 事件,尤其是在 DPI 较高的设备上,下面是一个根据最小偏移量来判断是否应该响应 Click 事件的一种方式
...
//拖动的最小偏移量
int MIN_OFFSET = 5;
//是否视为 click 事件
boolean isClick = false;
@Override
public boolean onTouch(View v, MotionEvent event) {
...
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isClick = true;
...
break;
case MotionEvent.ACTION_MOVE:
...
// 通过拖拽的距离是否超过最小偏移量来判断点击事件
if (Math.abs((rowX - downX)) > MIN_OFFSET && Math.abs((rowY - downY)) > MIN_OFFSET){
isClick = false;
}else {
isClick = true;
}
break;
case MotionEvent.ACTION_UP:
if (isClick){
// 执行点击事件
}
break;
default:
break;
}
return false;
}
最终改进
上述方式固然可以解决冲突问题,但是点击事件被放在 ACTION_UP 之下,或需要整个接口在外面调用很不优雅,下面的解决办法是通过父级 View 进行拦截,也就是将所有传进来的 View 先放入一个 ViewGroup 中,给这个 ViewGroup 设置 View.OnTouchListener,重写这个 ViewGroup 的 onInterceptTouchEvent 方法,根据拖拽的意图让它决定是否拦截所有事件不向下传递,从根本上解决冲突,并且把设置 Window 的属性相关也集成进去,外界只需传入一个 View 即可,下面是 FloatWindowUtils 全部实现过程
public class FloatWindowUtils {
private WindowManager.LayoutParams mLayoutParams;
private WindowManager mWindowManager;
private DisplayMetrics mDisplayMetrics;
//view 相对于屏幕触摸点的偏移量(一般仅减去Y轴状态栏高度)
int offsetX;
int offsetY;
//触摸点相对于view左上角的坐标
float downX;
float downY;
//触摸点相对于屏幕左上角的坐标
float rowX;
float rowY;
//悬浮窗显示标记
boolean isShowing;
//拖动最小偏移量
private static final int MINIMUM_OFFSET = 5;
private Context mContext;
//是否自动贴边
private boolean autoAlign;
//是否模态窗口
private boolean modality;
//是否可拖动
private boolean moveAble;
//内部定义的View,专门处理事件拦截的父View
private FloatView floatView;
//外部传进来的需要悬浮的View
private View contentView;
public FloatWindowUtils(Builder builder) {
this.mContext = builder.context;
this.autoAlign = builder.autoAlign;
this.modality = builder.modality;
this.contentView = builder.contentView;
this.moveAble = builder.moveAble;
initWindowManager();
initLayoutParams();
initFloatView();
}
private void initWindowManager() {
mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
//获取一个DisplayMetrics对象,该对象用来描述关于显示器的一些信息,例如其大小,密度和字体缩放。
mDisplayMetrics = new DisplayMetrics();
mWindowManager.getDefaultDisplay().getMetrics(mDisplayMetrics);
}
private void initFloatView() {
floatView = new FloatView(mContext);
if (moveAble) {
floatView.setOnTouchListener(new WindowTouchListener());
}
}
private void initLayoutParams() {
mLayoutParams = new WindowManager.LayoutParams();
mLayoutParams.flags = WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_DIM_BEHIND;
if (modality) {
mLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
mLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
}
mLayoutParams.height = LinearLayout.LayoutParams.WRAP_CONTENT;
mLayoutParams.width = LinearLayout.LayoutParams.WRAP_CONTENT;
mLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
mLayoutParams.format = PixelFormat.RGBA_8888;
//此处mLayoutParams.type不建议使用TYPE_TOAST,因为在一些三方ROM中会出现拖动异常的问题,虽然它不需要权限
mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
//悬浮窗背景明暗度0~1,数值越大背景越暗,只有在flags设置了WindowManager.LayoutParams.FLAG_DIM_BEHIND 这个属性才会生效
mLayoutParams.dimAmount = 0.0f;
//悬浮窗透明度0~1,数值越大越不透明
mLayoutParams.alpha = 0.8f;
offsetX = 0;
offsetY = getStatusBarHeight(mContext);
//设置初始位置
mLayoutParams.x = mDisplayMetrics.widthPixels - offsetX;
mLayoutParams.y = mDisplayMetrics.widthPixels*3/4 - offsetY;
}
/**
* 将窗体添加到屏幕上
*/
public void show() {
if (!isAppOps(mContext)){
// openOpsSettings(mContext);
// Toast.makeText(mContext,"需要授权应用悬浮权限",Toast.LENGTH_SHORT).show();
return;
}
if (!isShowing()) {
mWindowManager.addView(floatView, mLayoutParams);
isShowing = true;
}
}
/**
* 悬浮窗是否正在显示
*
* @return true if it's showing.
*/
private boolean isShowing() {
if (floatView != null && floatView.getVisibility() == View.VISIBLE) {
return isShowing;
}
return false;
}
/**
* 打开悬浮窗设置页
* 部分第三方ROM无法直接跳转可使用{@link #openAppSettings(Context)}跳到应用详情页
*
* @param context
* @return true if it's open successful.
*/
public static boolean openOpsSettings(Context context){
try {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
}catch (Exception e){
e.printStackTrace();
return false;
}
return true;
}
/**
* 打开应用详情页
* @param context
* @return true if it's open success.
*/
public static boolean openAppSettings(Context context){
try {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", context.getPackageName(), null);
intent.setData(uri);
context.startActivity(intent);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 判断 悬浮窗口权限是否打开
* 由于android未提供直接跳转到悬浮窗设置页的api,此方法使用反射去查找相关函数进行跳转
* 部分第三方ROM可能不适用
* @param context
* @return true 允许 false禁止
*/
public static boolean isAppOps(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] = Integer.valueOf(24);
arrayOfObject1[1] = Integer.valueOf(Binder.getCallingUid());
arrayOfObject1[2] = context.getPackageName();
int m = ((Integer) method.invoke(object, arrayOfObject1)).intValue();
return m == AppOpsManager.MODE_ALLOWED;
} catch (Exception ex) {
}
return false;
}
/**
* 移除悬浮窗
*/
public void remove() {
if (isShowing()) {
floatView.removeView(contentView);
mWindowManager.removeView(floatView);
isShowing = false;
}
}
/**
* 用于获取系统状态栏的高度。
*
* @return 返回状态栏高度的像素值。
*/
public static int getStatusBarHeight(Context ctx) {
int Identifier = ctx.getResources().getIdentifier("status_bar_height",
"dimen", "android");
if (Identifier > 0) {
return ctx.getResources().getDimensionPixelSize(Identifier);
}
return 0;
}
class FloatView extends LinearLayout{
//记录按下位置
int interceptX=0;
int interceptY=0;
public FloatView(Context context) {
super(context);
//这里由于一个ViewGroup不能add一个已经有Parent的contentView,所以需要先判断contentView是否有Parent
//如果有则需要将contentView先移除
if (contentView.getParent()!=null&&contentView.getParent() instanceof ViewGroup){
((ViewGroup) contentView.getParent()).removeView(contentView);
}
addView(contentView);
}
/**
* 解决点击与拖动冲突的关键代码
* @param ev
* @return
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//此回调如果返回true则表示拦截TouchEvent由自己处理,false表示不拦截TouchEvent分发出去由子view处理
//解决方案:如果是拖动父View则返回true调用自己的onTouch改变位置,是点击则返回false去响应子view的点击事件
boolean isIntercept = false;
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
interceptX = (int) ev.getX();
interceptY = (int) ev.getY();
downX = ev.getX();
downY = ev.getY();
isIntercept = false;
break;
case MotionEvent.ACTION_MOVE:
//在一些dpi较高的设备上点击view很容易触发 ACTION_MOVE,所以此处做一个过滤
if (Math.abs(ev.getX()-interceptX)>MINIMUM_OFFSET&&Math.abs(ev.getY()-interceptY)>MINIMUM_OFFSET){
isIntercept = true;
}else {
isIntercept = false;
}
break;
case MotionEvent.ACTION_UP:
isIntercept = false;
break;
default:
break;
}
return isIntercept;
}
}
class WindowTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View v, MotionEvent event) {
//获取触摸点相对于屏幕左上角的坐标
rowX = event.getRawX();
rowY = event.getRawY() - getStatusBarHeight(mContext);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
actionDown(event);
break;
case MotionEvent.ACTION_MOVE:
actionMove(event);
break;
case MotionEvent.ACTION_UP:
actionUp(event);
break;
case MotionEvent.ACTION_OUTSIDE:
actionOutSide(event);
break;
default:
break;
}
return false;
}
/**
* 手指点击窗口外的事件
*
* @param event
*/
private void actionOutSide(MotionEvent event) {
//由于我们在layoutParams中添加了FLAG_WATCH_OUTSIDE_TOUCH标记,那么点击悬浮窗之外时此事件就会被响应
//这里可以用来扩展点击悬浮窗外部响应事件
}
/**
* 手指抬起事件
*
* @param event
*/
private void actionUp(MotionEvent event) {
if (autoAlign) {
autoAlign();
}
}
/**
* 拖动事件
*
* @param event
*/
private void actionMove(MotionEvent event) {
//拖动事件下一直计算坐标 然后更新悬浮窗位置
updateLocation((rowX - downX),(rowY - downY));
}
/**
* 更新位置
*/
private void updateLocation(float x, float y) {
mLayoutParams.x = (int) x;
mLayoutParams.y = (int) y;
mWindowManager.updateViewLayout(floatView, mLayoutParams);
}
/**
* 手指按下事件
*
* @param event
*/
private void actionDown(MotionEvent event) {
// downX = event.getX();
// downY = event.getY();
}
/**
* 自动贴边
*/
private void autoAlign() {
float fromX = mLayoutParams.x;
if (rowX <= mDisplayMetrics.widthPixels / 2) {
mLayoutParams.x = 0;
} else {
mLayoutParams.x = mDisplayMetrics.widthPixels;
}
//这里使用ValueAnimator来平滑计算起始X坐标到结束X坐标之间的值,并更新悬浮窗位置
ValueAnimator animator = ValueAnimator.ofFloat(fromX, mLayoutParams.x);
animator.setDuration(300);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//这里会返回fromX ~ mLayoutParams.x之间经过计算的过渡值
float toX = (float) animation.getAnimatedValue();
//我们直接使用这个值来更新悬浮窗位置
updateLocation(toX, mLayoutParams.y);
}
});
animator.start();
}
}
public static class Builder {
private Context context;
private boolean autoAlign;
private boolean modality;
private View contentView;
private boolean moveAble;
/**
* @param context 上下文环境
* @param contentView 需要悬浮的视图
*/
public Builder(Context context, @NonNull View contentView) {
this.context = context.getApplicationContext();
this.contentView = contentView;
}
/**
* 是否自动贴边
* @param autoAlign
* @return
*/
public Builder setAutoAlign(boolean autoAlign) {
this.autoAlign = autoAlign;
return this;
}
/**
* 是否模态窗口(事件是否可穿透当前窗口)
* @param modality
* @return
*/
public Builder setModality(boolean modality) {
this.modality = modality;
return this;
}
/**
* 是否可拖动
* @param moveAble
* @return
*/
public Builder setMoveAble(boolean moveAble) {
this.moveAble = moveAble;
return this;
}
public FloatWindowUtils create() {
return new FloatWindowUtils(this);
}
}
}
调用方式
Button mBtn = new Button(this);
mBtn.setText("悬浮按钮");
mBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(context,"click",Toast.LENGTH_SHORT).show();
}
});
new FloatWindowUtils.Builder(context,mBtn)
.setAutoAlign(true) //自动贴边
.setModality(false) //模态窗体
.setMoveAble(true) //可拖动
.create()
.show();
网友评论