悬浮窗即可以显示在宿主应用之外的 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 。
example.gif
本着实现一个简单的、轻量级的工具类的目的,通过传入一个任意 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 全部实现过程
package cc.skyrin.autojumper.util; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.app.AppOpsManager; import android.content.Context; import android.content.Intent; import android.graphics.PixelFormat; import android.net.Uri; import android.os.Binder; import android.os.Build; import android.provider.Settings; import android.support.annotation.NonNull; import android.util.DisplayMetrics; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.LinearLayout; import java.lang.reflect.Method; /** * Created by skyrin on 2017/3/16. */ public class FloatWindow { private WindowManager.LayoutParams mLayoutParams; private WindowManager mWindowManager; private DisplayMetrics mDisplayMetrics; /** * 触摸点相对于view左上角的坐标 */ private float downX; private float downY; /** * 触摸点相对于屏幕左上角的坐标 */ private float rowX; private float rowY; /** * 悬浮窗显示标记 */ private boolean isShowing; /** * 拖动最小偏移量 */ private static final int MINIMUM_OFFSET = 5; private Context mContext; /** * 是否自动贴边 */ private boolean autoAlign; /** * 是否模态窗口 */ private boolean modality; /** * 是否可拖动 */ private boolean moveAble; /** * 透明度 */ private float alpha; /** * 初始位置 */ private int startX; private int startY; /** * View 高度 */ private int height; /** * View 宽度 */ private int width; /** * 内部定义的View,专门处理事件拦截的父View */ private FloatView floatView; /** * 外部传进来的需要悬浮的View */ private View contentView; private FloatWindow(With with) { this.mContext = with.context; this.autoAlign = with.autoAlign; this.modality = with.modality; this.contentView = with.contentView; this.moveAble = with.moveAble; this.startX = with.startX; this.startY = with.startY; this.alpha = with.alpha; this.height = with.height; this.width = with.width; initWindowManager(); initLayoutParams(); initFloatView(); } private void initWindowManager() { mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); //获取一个DisplayMetrics对象,该对象用来描述关于显示器的一些信息,例如其大小,密度和字体缩放。 mDisplayMetrics = new DisplayMetrics(); mWindowManager.getDefaultDisplay().getMetrics(mDisplayMetrics); } @SuppressLint({"ClickableViewAccessibility"}) 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; if (modality) { mLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; mLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; } mLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT; mLayoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT; if (height!=WindowManager.LayoutParams.WRAP_CONTENT){ mLayoutParams.height = WindowManager.LayoutParams.MATCH_PARENT; } if (width!=WindowManager.LayoutParams.WRAP_CONTENT){ mLayoutParams.width = WindowManager.LayoutParams.MATCH_PARENT; } mLayoutParams.gravity = Gravity.START | Gravity.TOP; mLayoutParams.format = PixelFormat.RGBA_8888; //此处mLayoutParams.type不建议使用TYPE_TOAST,因为在一些版本较低的系统中会出现拖动异常的问题,虽然它不需要权限 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { mLayoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; } //悬浮窗背景明暗度0~1,数值越大背景越暗,只有在flags设置了WindowManager.LayoutParams.FLAG_DIM_BEHIND 这个属性才会生效 mLayoutParams.dimAmount = 0.0f; //悬浮窗透明度0~1,数值越大越不透明 mLayoutParams.alpha = alpha; //悬浮窗起始位置 mLayoutParams.x = startX; mLayoutParams.y = startY; } /** * 将窗体添加到屏幕上 */ @SuppressLint("NewApi") public void show() { if (!isAppOpsOn(mContext)) { 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 { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName())); context.startActivity(intent); } else { return openAppSettings(context); } } 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 isAppOpsOn(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return Settings.canDrawOverlays(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) { ex.getStackTrace(); } return false; } /** * 移除悬浮窗 */ public void remove() { if (isShowing()) { floatView.removeView(contentView); mWindowManager.removeView(floatView); isShowing = false; } } /** * 用于获取系统状态栏的高度。 * * @return 返回状态栏高度的像素值。 */ private 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 FrameLayout { /** * 记录按下位置 */ 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,所以此处做一个过滤 isIntercept = Math.abs(ev.getX() - interceptX) > MINIMUM_OFFSET && Math.abs(ev.getY() - interceptY) > MINIMUM_OFFSET; break; case MotionEvent.ACTION_UP: break; default: break; } return isIntercept; } } class WindowTouchListener implements View.OnTouchListener { @SuppressLint("ClickableViewAccessibility") @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(animation -> { //这里会返回fromX ~ mLayoutParams.x之间经过计算的过渡值 float toX = (float) animation.getAnimatedValue(); //我们直接使用这个值来更新悬浮窗位置 updateLocation(toX, mLayoutParams.y); }); animator.start(); } } public static class With { private Context context; private boolean autoAlign; private boolean modality; private View contentView; private boolean moveAble; private float alpha = 1f; /** * View 高度 */ private int height = WindowManager.LayoutParams.WRAP_CONTENT; /** * View 宽度 */ private int width = WindowManager.LayoutParams.WRAP_CONTENT; /** * 初始位置 */ private int startX; private int startY; /** * @param context 上下文环境 * @param contentView 需要悬浮的视图 */ public With(Context context, @NonNull View contentView) { this.context = context; this.contentView = contentView; } /** * 是否自动贴边 * * @param autoAlign * @return */ public With setAutoAlign(boolean autoAlign) { this.autoAlign = autoAlign; return this; } /** * 是否模态窗口(事件是否可穿透当前窗口) * * @param modality * @return */ public With setModality(boolean modality) { this.modality = modality; return this; } /** * 是否可拖动 * * @param moveAble * @return */ public With setMoveAble(boolean moveAble) { this.moveAble = moveAble; return this; } /** * 设置起始位置 * * @param startX * @param startY * @return */ public With setStartLocation(int startX, int startY) { this.startX = startX; this.startY = startY; return this; } public With setAlpha(float alpha) { this.alpha = alpha; return this; } public With setHeight(int height) { this.height = height; return this; } public With setWidth(int width) { this.width = width; return this; } public FloatWindow create() { return new FloatWindow(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(); } }); FloatWindow floatWindow = new FloatWindow.With(this, layout) .setModality(false) .setMoveAble(true) .setAutoAlign(true) .setAlpha(0.5f) .setWidth(WindowManager.LayoutParams.WRAP_CONTENT) .setHeight(WindowManager.LayoutParams.MATCH_PARENT) .create(); // 显示 floatWindow.show(); // 移除 floatWindow.remove();
本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。
我来说两句