懸浮窗
懸浮窗即可以顯示在宿主應(yīng)用之外的 View 視圖,理論上任何 View 都能以懸浮窗形式展示在宿主應(yīng)用之外甚至鎖屏界面,一般在工具類應(yīng)用中使用的比較多,通過懸浮窗可以很方便的從外界與宿主應(yīng)用進(jìn)行交互,例如金山詞霸的鎖屏單詞功能、AirDroid 的錄制屏幕菜單、360優(yōu)化大師的清理懸浮按鈕等。
需要了解的
Window
Window 表示一個(gè)窗口的概念,在日常開發(fā)中直接接觸 Window 的機(jī)會(huì)并不多,但是在特殊時(shí)候我們需要在桌面顯示一個(gè)類似懸浮窗的東西,那么這種效果就需要用到 Window 來實(shí)現(xiàn)。Window 是一個(gè)抽象類,它的具體實(shí)現(xiàn)是 PhoneWindow。創(chuàng)建一個(gè) Window 非常簡單,我們通過 WindowManager 即可完成。 Android 中所有視圖都是通過 Window 來呈現(xiàn)的,不管是 Activity、Dialog、還是 Toast,它們的視圖實(shí)際上都是附加在 Window 上的。
WindowManager
應(yīng)用程序用于與窗口管理器通信的接口,是外界訪問 Window 的入口,使用 Context.getSystemService(Context.WINDOW_SERVICE) 獲取它的實(shí)例。WindowManager提供了addView(View view, ViewGroup.LayoutParams params),removeView(View view),updateViewLayout(View view, ViewGroup.LayoutParams params)三個(gè)方法用來向設(shè)備屏幕 添加、移除以及更新 一個(gè) view 。
WindowManager.LayoutParams
通過名字就可以看出來 它是WindowManager的一個(gè)內(nèi)部類,專門用來描述 view 的屬性 比如大小、透明度 、初始位置、視圖層級等。
DisplayMetrics
該對象用來描述關(guān)于顯示器的一些信息,例如其大小,密度和字體縮放。例如獲取屏幕寬度DisplayMetrics.widthPixels 。
最終效果
實(shí)現(xiàn)思路
本著實(shí)現(xiàn)一個(gè)簡單的、輕量級的工具類的目的,通過傳入一個(gè)任意 View 可以將其創(chuàng)建成可自由拖動(dòng)的懸浮窗
懸浮一個(gè) View
首先我們知道 View 能顯示在屏幕上其實(shí)是間接通過 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,因?yàn)?Android 設(shè)備屏幕坐標(biāo)原點(diǎn)在左上角
mLayoutParams.x = 100; //距離屏幕左側(cè)100px
mLayoutParams.y = 300; //距離屏幕上方300px
mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; //指定 Window 類型為 TYPE_SYSTEM_ALERT,屬于系統(tǒng)級別,就可以顯示在系統(tǒng)屏幕上了
final WindowManager mWindowManager = getWindowManager();
mWindowManager.addView(mBtn,mLayoutParams);
別忘了系統(tǒng)級窗口權(quán)限
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
效果如下
使其可以拖動(dòng)
顯然上面的 Button 只是能顯示在系統(tǒng)屏幕上而已,并不能拖動(dòng),要使其能夠拖動(dòng)就要給它設(shè)置一個(gè) View.OnTouchListener 來監(jiān)聽手指在屏幕上滑動(dòng)的坐標(biāo)然后根據(jù)這個(gè)坐標(biāo)設(shè)置其位置,如下實(shí)現(xiàn)
mBtn.setOnTouchListener(new View.OnTouchListener() {
//觸摸點(diǎn)相對于view左上角的坐標(biāo)
float downX;
float downY;
@Override
public boolean onTouch(View v, MotionEvent event) {
//獲取觸摸點(diǎn)相對于屏幕左上角的坐標(biāo)
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); //計(jì)算當(dāng)前觸摸點(diǎn)相對于屏幕左上角的 X 軸位置
mLayoutParams.y = (int) (rowY - downY); //計(jì)算當(dāng)前觸摸點(diǎn)相對于屏幕左上角的 Y 軸位置
mWindowManager.updateViewLayout(mBtn, mLayoutParams); //更新 Button 到相應(yīng)位置
break;
case MotionEvent.ACTION_UP:
//actionUp(event);
break;
case MotionEvent.ACTION_OUTSIDE:
//actionOutSide(event);
break;
default:
break;
}
return false;
}
});
解決點(diǎn)擊和滑動(dòng)的事件沖突
現(xiàn)在這個(gè) Button 雖然可以跟著你的手指移動(dòng)了,但是你會(huì)發(fā)現(xiàn)當(dāng)你拖動(dòng)一段較小距離時(shí)會(huì)有很大幾率響應(yīng)它的 Click 事件,這顯然不能接受,在拖動(dòng)這個(gè) Button 的整個(gè)過程中會(huì)依次觸發(fā) ACTION_DOWN、ACTION_MOVE、ACTION_MOVE、... 、ACTION_UP,當(dāng) ACTION_MOVE 被觸發(fā)時(shí) ACTION_DOWN 會(huì)被釋放,之后松開手指觸發(fā) ACTION_UP 是不會(huì)響應(yīng) Click 事件的, Click 事件的響應(yīng)條件是 ACTION_DOWN + ACTION_UP,所以當(dāng)我們拖動(dòng)一個(gè)很小的距離時(shí)很容易造成 ACTION_DOWN 與 ACTION_UP 的連續(xù)觸發(fā)而響應(yīng)了 Click 事件,尤其是在 DPI 較高的設(shè)備上,下面是一個(gè)根據(jù)最小偏移量來判斷是否應(yīng)該響應(yīng) Click 事件的一種方式
...
//拖動(dòng)的最小偏移量
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:
...
// 通過拖拽的距離是否超過最小偏移量來判斷點(diǎn)擊事件
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){
// 執(zhí)行點(diǎn)擊事件
}
break;
default:
break;
}
return false;
}
最終改進(jìn)
上述方式固然可以解決沖突問題,但是點(diǎn)擊事件被放在 ACTION_UP 之下,或需要整個(gè)接口在外面調(diào)用很不優(yōu)雅,下面的解決辦法是通過父級 View 進(jìn)行攔截,也就是將所有傳進(jìn)來的 View 先放入一個(gè) ViewGroup 中,給這個(gè) ViewGroup 設(shè)置 View.OnTouchListener,重寫這個(gè) ViewGroup 的 onInterceptTouchEvent 方法,根據(jù)拖拽的意圖讓它決定是否攔截所有事件不向下傳遞,從根本上解決沖突,并且把設(shè)置 Window 的屬性相關(guān)也集成進(jìn)去,外界只需傳入一個(gè) View 即可,下面是 FloatWindowUtils 全部實(shí)現(xiàn)過程
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;
/**
* 觸摸點(diǎn)相對于view左上角的坐標(biāo)
*/
private float downX;
private float downY;
/**
* 觸摸點(diǎn)相對于屏幕左上角的坐標(biāo)
*/
private float rowX;
private float rowY;
/**
* 懸浮窗顯示標(biāo)記
*/
private boolean isShowing;
/**
* 拖動(dòng)最小偏移量
*/
private static final int MINIMUM_OFFSET = 5;
private Context mContext;
/**
* 是否自動(dòng)貼邊
*/
private boolean autoAlign;
/**
* 是否模態(tài)窗口
*/
private boolean modality;
/**
* 是否可拖動(dòng)
*/
private boolean moveAble;
/**
* 透明度
*/
private float alpha;
/**
* 初始位置
*/
private int startX;
private int startY;
/**
* View 高度
*/
private int height;
/**
* View 寬度
*/
private int width;
/**
* 內(nèi)部定義的View,專門處理事件攔截的父View
*/
private FloatView floatView;
/**
* 外部傳進(jìn)來的需要懸浮的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);
//獲取一個(gè)DisplayMetrics對象,該對象用來描述關(guān)于顯示器的一些信息,例如其大小,密度和字體縮放。
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,因?yàn)樵谝恍┌姹据^低的系統(tǒng)中會(huì)出現(xiàn)拖動(dòng)異常的問題,雖然它不需要權(quán)限
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,數(shù)值越大背景越暗,只有在flags設(shè)置了WindowManager.LayoutParams.FLAG_DIM_BEHIND 這個(gè)屬性才會(huì)生效
mLayoutParams.dimAmount = 0.0f;
//懸浮窗透明度0~1,數(shù)值越大越不透明
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;
}
/**
* 打開懸浮窗設(shè)置頁
* 部分第三方ROM無法直接跳轉(zhuǎn)可使用{@link #openAppSettings(Context)}跳到應(yīng)用詳情頁
*
* @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;
}
/**
* 打開應(yīng)用詳情頁
*
* @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;
}
/**
* 判斷 懸浮窗口權(quán)限是否打開
* 由于android未提供直接跳轉(zhuǎn)到懸浮窗設(shè)置頁的api,此方法使用反射去查找相關(guān)函數(shù)進(jìn)行跳轉(zhuǎn)
* 部分第三方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;
}
}
/**
* 用于獲取系統(tǒng)狀態(tài)欄的高度。
*
* @return 返回狀態(tài)欄高度的像素值。
*/
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);
//這里由于一個(gè)ViewGroup不能add一個(gè)已經(jīng)有Parent的contentView,所以需要先判斷contentView是否有Parent
//如果有則需要將contentView先移除
if (contentView.getParent() != null && contentView.getParent() instanceof ViewGroup) {
((ViewGroup) contentView.getParent()).removeView(contentView);
}
addView(contentView);
}
/**
* 解決點(diǎn)擊與拖動(dòng)沖突的關(guān)鍵代碼
*
* @param ev
* @return
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//此回調(diào)如果返回true則表示攔截TouchEvent由自己處理,false表示不攔截TouchEvent分發(fā)出去由子view處理
//解決方案:如果是拖動(dòng)父View則返回true調(diào)用自己的onTouch改變位置,是點(diǎn)擊則返回false去響應(yīng)子view的點(diǎn)擊事件
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較高的設(shè)備上點(diǎn)擊view很容易觸發(fā) ACTION_MOVE,所以此處做一個(gè)過濾
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) {
//獲取觸摸點(diǎn)相對于屏幕左上角的坐標(biāo)
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;
}
/**
* 手指點(diǎn)擊窗口外的事件
*
* @param event
*/
private void actionOutSide(MotionEvent event) {
//由于我們在layoutParams中添加了FLAG_WATCH_OUTSIDE_TOUCH標(biāo)記,那么點(diǎn)擊懸浮窗之外時(shí)此事件就會(huì)被響應(yīng)
//這里可以用來擴(kuò)展點(diǎn)擊懸浮窗外部響應(yīng)事件
}
/**
* 手指抬起事件
*
* @param event
*/
private void actionUp(MotionEvent event) {
if (autoAlign) {
autoAlign();
}
}
/**
* 拖動(dòng)事件
*
* @param event
*/
private void actionMove(MotionEvent event) {
//拖動(dòng)事件下一直計(jì)算坐標(biāo) 然后更新懸浮窗位置
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();
}
/**
* 自動(dòng)貼邊
*/
private void autoAlign() {
float fromX = mLayoutParams.x;
if (rowX <= mDisplayMetrics.widthPixels / 2) {
mLayoutParams.x = 0;
} else {
mLayoutParams.x = mDisplayMetrics.widthPixels;
}
//這里使用ValueAnimator來平滑計(jì)算起始X坐標(biāo)到結(jié)束X坐標(biāo)之間的值,并更新懸浮窗位置
ValueAnimator animator = ValueAnimator.ofFloat(fromX, mLayoutParams.x);
animator.setDuration(300);
animator.addUpdateListener(animation -> {
//這里會(huì)返回fromX ~ mLayoutParams.x之間經(jīng)過計(jì)算的過渡值
float toX = (float) animation.getAnimatedValue();
//我們直接使用這個(gè)值來更新懸浮窗位置
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 上下文環(huán)境
* @param contentView 需要懸浮的視圖
*/
public With(Context context, @NonNull View contentView) {
this.context = context;
this.contentView = contentView;
}
/**
* 是否自動(dòng)貼邊
*
* @param autoAlign
* @return
*/
public With setAutoAlign(boolean autoAlign) {
this.autoAlign = autoAlign;
return this;
}
/**
* 是否模態(tài)窗口(事件是否可穿透當(dāng)前窗口)
*
* @param modality
* @return
*/
public With setModality(boolean modality) {
this.modality = modality;
return this;
}
/**
* 是否可拖動(dòng)
*
* @param moveAble
* @return
*/
public With setMoveAble(boolean moveAble) {
this.moveAble = moveAble;
return this;
}
/**
* 設(shè)置起始位置
*
* @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);
}
}
}
調(diào)用方式
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();