前陣子公司項(xiàng)目中需要大量使用Android懸浮窗去實(shí)現(xiàn)一些功能疏遏,對(duì)公司之前一團(tuán)糟的代碼結(jié)構(gòu)和面對(duì)國(guó)產(chǎn)Android奇(沙)葩(雕)的機(jī)型適配(對(duì)猪叙,我說(shuō)的奇(沙)葩(雕)機(jī)型就是Vivo)讓我不得不去思考一個(gè)完整的懸浮窗解決方案。在這個(gè)背景下完成了項(xiàng)目任務(wù)后挪凑,做一個(gè)總結(jié),回顧開(kāi)發(fā)的問(wèn)題和經(jīng)驗(yàn)總結(jié)。
不如首先搞個(gè)Demo纺酸!體驗(yàn)Demo地址:https://github.com/SunJenry/FloatWindow
1.添加懸浮窗原理
添加懸浮窗的原理就是獲取WindowManager,然后調(diào)用WindowManager的 addView(View view, ViewGroup.LayoutParams params)
方法即可址否。View
就是需要顯示的懸浮窗餐蔬, ViewGroup.LayoutParams包含了是全屏、非全屏和是否需要攔截觸摸事件的參數(shù)佑附。 考慮的懸浮窗存在全屏和非全屏樊诺、攔截觸摸事件和不攔截觸摸事件的情況可以?xún)蓛山M合產(chǎn)生4種情況:
1.全屏攔截觸摸事件
2.全屏不攔截觸摸事件
3.非全屏(wrap_content)攔截觸摸事件
4.非全屏(wrap_content)不攔截觸摸事件
綜合對(duì)懸浮窗增加、移除音同、隱藏词爬、是否可以可鍵盤(pán)交互等等操作可以提出一個(gè)基類(lèi)AbsFloatBase
(大家先粗略建立一個(gè)映像,下面的文章會(huì)針對(duì)具體的方法做詳細(xì)解釋?zhuān)?/p>
public abstract class AbsFloatBase {
public static final String TAG = "AbsFloatBase";
static final int FULLSCREEN_TOUCHABLE = 1;
static final int FULLSCREEN_NOT_TOUCHABLE = 2;
static final int WRAP_CONTENT_TOUCHABLE = 3;
static final int WRAP_CONTENT_NOT_TOUCHABLE = 4;
WindowManager.LayoutParams mLayoutParams;
View mInflate;
Context mContext;
WindowManager mWindowManager;
private boolean mAdded;
//設(shè)置隱藏時(shí)是否是INVISIBLE
private boolean mInvisibleNeed = false;
private boolean mRequestFocus = false;
int mGravity = Gravity.CENTER_HORIZONTAL | Gravity.CENTER_VERTICAL;
int mViewMode = WRAP_CONTENT_NOT_TOUCHABLE;
Handler mHandler = new Handler(Looper.getMainLooper());
public AbsFloatBase(Context context) {
mContext = context;
create();
}
/**
* 設(shè)置隱藏View的方式是否為Invisible权均,默認(rèn)為Gone
*
* @param invisibleNeed 是否是Invisible
*/
public void setInvisibleNeed(boolean invisibleNeed) {
mInvisibleNeed = invisibleNeed;
}
/**
* 懸浮窗是否需要獲取焦點(diǎn)缸夹,通常獲取焦點(diǎn)后痪寻,懸浮窗可以和軟鍵盤(pán)發(fā)生交互,被覆蓋的應(yīng)用失去焦點(diǎn)虽惭。
* 例如:游戲?qū)⑹ケ尘耙魳?lè)
*
* @param requestFocus
*/
public void requestFocus(boolean requestFocus) {
mRequestFocus = requestFocus;
}
@CallSuper
public void create() {
mWindowManager = (WindowManager) mContext.getApplicationContext().getSystemService(WINDOW_SERVICE);
}
@CallSuper
public synchronized void show() {
if (mInflate == null)
throw new IllegalStateException("FloatView can not be null");
if (mAdded) {
mInflate.setVisibility(View.VISIBLE);
return;
}
getLayoutParam(mViewMode);
mInflate.setVisibility(View.VISIBLE);
try {
mWindowManager.addView(mInflate, mLayoutParams);
mAdded = true;
} catch (Exception e) {
Log.e(TAG, "添加懸浮窗失斚鹄唷!Q看健9嘶!4殷浴研侣!");
Toast.makeText(mContext, "添加懸浮窗失敗E谂酢J睢!E乜巍D┦摹!請(qǐng)檢查懸浮窗權(quán)限", Toast.LENGTH_SHORT).show();
}
}
@CallSuper
public void hide() {
if (mInflate != null) {
mInflate.setVisibility(View.INVISIBLE);
}
}
@CallSuper
public void gone() {
if (mInflate != null) {
mInflate.setVisibility(View.GONE);
}
}
@CallSuper
public void remove() {
if (mInflate != null && mWindowManager != null) {
if (mInflate.isAttachedToWindow()) {
mWindowManager.removeView(mInflate);
}
mAdded = false;
}
if (mHandler != null) {
mHandler.removeCallbacksAndMessages(null);
}
}
@CallSuper
protected View inflate(@LayoutRes int layout) {
mInflate = View.inflate(mContext, layout, null);
return mInflate;
}
@SuppressWarnings("unchecked")
protected <T extends View> T findView(@IdRes int id) {
if (mInflate != null) {
return (T) mInflate.findViewById(id);
}
return null;
}
/**
* 獲取懸浮窗LayoutParam
*
* @param mode
*/
protected void getLayoutParam(int mode) {
switch (mode) {
case FULLSCREEN_TOUCHABLE:
mLayoutParams = FloatWindowParamManager.getFloatLayoutParam(true, true);
break;
case FULLSCREEN_NOT_TOUCHABLE:
mLayoutParams = FloatWindowParamManager.getFloatLayoutParam(true, false);
break;
case WRAP_CONTENT_NOT_TOUCHABLE:
mLayoutParams = FloatWindowParamManager.getFloatLayoutParam(false, false);
break;
case WRAP_CONTENT_TOUCHABLE:
mLayoutParams = FloatWindowParamManager.getFloatLayoutParam(false, true);
break;
}
if (mRequestFocus) {
mLayoutParams.flags = mLayoutParams.flags & ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
}
mLayoutParams.gravity = mGravity;
}
/**
* 獲取可見(jiàn)性
*
* @return
*/
public boolean getVisibility() {
if (mInflate != null && mInflate.getVisibility() == View.VISIBLE) {
return true;
} else {
return false;
}
}
/**
* 改變可見(jiàn)性
*/
public void toggleVisibility() {
if (mInflate != null) {
if (getVisibility()) {
if (mInvisibleNeed) {
hide();
} else {
gone();
}
} else {
show();
}
}
}
}
2.ABSFloatBase使用
這里建議下載上面提供的Demo結(jié)合會(huì)更方便理解书蚪。那么通過(guò)繼承ABSFloatBase
實(shí)現(xiàn)一個(gè)簡(jiǎn)單懸浮窗吧喇澡。以下圖懸浮窗為例:
具體代碼如下:
public class FloatPermissionDetectView extends AbsFloatBase {
public FloatPermissionDetectView(Context context) {
super(context);
}
@Override
public void create() {
super.create();
mViewMode = WRAP_CONTENT_TOUCHABLE;
mGravity = Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL;
inflate(R.layout.main_layout_float_permission_detect);
findView(R.id.btn_close).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
remove();
}
});
}
}
在需要顯示的地方調(diào)用:
mFloatPermissionDetectView = new FloatPermissionDetectView(getApplicationContext());
mFloatPermissionDetectView.show();
即可。怎么樣殊校,簡(jiǎn)單吧晴玖。先對(duì)這里面的代碼做個(gè)簡(jiǎn)單的解釋?zhuān)?br>
1.首先重載create()
,在這里設(shè)置自定義布局为流,對(duì)應(yīng)的代碼就是:inflate(R.layout.main_layout_float_permission_detect);
2.mViewMode = WRAP_CONTENT_TOUCHABLE;
用來(lái)設(shè)置當(dāng)前懸浮窗是wrap_content并且攔截觸摸事件的呕屎,與之相對(duì)應(yīng)的還有FULLSCREEN_TOUCHABLE
、FULLSCREEN_NOT_TOUCHABLE
敬察、WRAP_CONTENT_NOT_TOUCHABLE
秀睛,看名字就理解這些參數(shù)是什么作用,我就不一一解釋了静汤。
3.mGravity = Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL;
當(dāng)懸浮窗是非全屏類(lèi)型的琅催,可以設(shè)置懸浮窗是展示在屏幕的上下左右、還是中間虫给。其他的類(lèi)型大家可以自行探索藤抡。這里面基類(lèi)中默認(rèn)就是居中類(lèi)型的,所以這里相當(dāng)于沒(méi)有設(shè)置抹估。(為什么相當(dāng)于沒(méi)有設(shè)置我還寫(xiě)缠黍?因?yàn)槲蚁胝故疽幌虏僮鳌#?br>
4.調(diào)用findView(int id)
方法找到布局中的控件id药蜻,這部分和Activity中一樣瓷式,就不詳細(xì)解釋了替饿。至于獲取到控件之后嘛,當(dāng)然是嘿嘿嘿咯贸典。比如上面點(diǎn)擊之后調(diào)用remove()
方法移除懸浮窗视卢。
5.在需要的地方new一個(gè)對(duì)象,調(diào)用show()
方法廊驼,即可展示懸浮窗据过。
其他幾種類(lèi)型可以自行去感受下,在不攔截觸摸事件的類(lèi)型中妒挎,懸浮窗下面的控件是可以接收到觸摸事件的绳锅。
3.和輸入法交互
按上面的方法寫(xiě)出來(lái)的懸浮窗是無(wú)法呼出輸入法的,這是因?yàn)閃indowManager的 addView(View view, ViewGroup.LayoutParams params)
中ViewGroup.LayoutParams params
設(shè)置了WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
酝掩,這個(gè)Flag不會(huì)影響被覆蓋應(yīng)用的行為鳞芙,如果需要在懸浮窗中呼出輸入法就需要去除這個(gè)Flag,這時(shí)就需要調(diào)用基類(lèi)的requestFocus(true);
方法期虾。和Demo中相對(duì)應(yīng)的就是InputWindow
原朝,具體代碼如下:
public class InputWindow extends AbsFloatBase {
public InputWindow(Context context) {
super(context);
}
@Override
public void create() {
super.create();
mViewMode = WRAP_CONTENT_TOUCHABLE;
inflate(R.layout.main_layout_input_window);
requestFocus(true);
findView(R.id.btn_close).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
remove();
}
});
}
}
調(diào)用requestFocus(true);
這個(gè)方法會(huì)發(fā)生什么呢?除了可以呼出鍵盤(pán)外彻消,被覆蓋的應(yīng)用失去焦點(diǎn)竿拆,就不能再處理輸入事件宙拉,比如如果覆蓋在游戲上宾尚,游戲會(huì)暫停背景音樂(lè)。
4.關(guān)于懸浮窗權(quán)限
如果你安裝完應(yīng)用直接點(diǎn)擊后幾個(gè)按鈕想要添加懸浮窗的話(huà)谢澈,會(huì)發(fā)現(xiàn)沒(méi)有添加任何懸浮窗煌贴,而且會(huì)得到Toast報(bào)錯(cuò)。(Vivo手機(jī)除外哈锥忿,具體原因稍候解釋?zhuān)?br>
這是因?yàn)樘砑討腋〈靶枰獞腋〈皺?quán)限牛郑,準(zhǔn)確的講就是
允許顯示在其他應(yīng)用的上層
的權(quán)限。
如果沒(méi)有獲取該權(quán)限敬鬓,點(diǎn)擊
檢查懸浮窗權(quán)限
就會(huì)彈出跳轉(zhuǎn)該頁(yè)面的懸浮窗淹朋,確定后就會(huì)跳轉(zhuǎn)到該頁(yè)面。當(dāng)然在國(guó)內(nèi)魔改ROM的悲慘狀況下钉答,這個(gè)頁(yè)面被以各種形式展現(xiàn)到各個(gè)地方:
還有各種各樣的樣式我就不一一例舉了础芍。至于跳轉(zhuǎn)到這個(gè)頁(yè)面的方法也是各不相同,好在Demo里面已經(jīng)處理了大部分機(jī)型了数尿,根據(jù)目前公司的反饋仑性,已經(jīng)可以處理相當(dāng)多的機(jī)型了∮冶模考慮到百萬(wàn)級(jí)的安裝量诊杆,和幾十款測(cè)試機(jī)型歼捐,勉強(qiáng)湊合用吧,實(shí)在不行就只能自行手動(dòng)打開(kāi)了晨汹。對(duì)不起豹储,在國(guó)產(chǎn)ROM面前,我淘这,敗了颂翼。
那么怎么檢查應(yīng)用是否有允許顯示在其他應(yīng)用的上層
的權(quán)限呢?Demo中提供的FloatWindowParamManager.checkPermission(getApplicationContext());
方法就可以了慨灭。當(dāng)然如果事情是這樣朦乏,那么世界該是多么美好!然鵝氧骤,在Vivo面前呻疹,我,又?jǐn)×耍?br>
對(duì)于大部分機(jī)型而言筹陵,這個(gè)方法算得上是一個(gè)不錯(cuò)的解決方案刽锤,但是遇到Vivo這樣的奇(沙)葩(雕),會(huì)遇到一些難言的困惑朦佩。比如在Vivo手機(jī)上并思,當(dāng)你點(diǎn)擊Button檢查權(quán)限時(shí),FloatWindowParamManager.checkPermission(getApplicationContext());
一直返回true
语稠,但是當(dāng)應(yīng)用處于后臺(tái)時(shí)宋彼,試圖添加懸浮窗又會(huì)失敗仙畦!已經(jīng)添加的懸浮窗會(huì)消失输涕!經(jīng)過(guò)一番令人驚奇的操作后發(fā)現(xiàn),原來(lái)Vivo在應(yīng)用前臺(tái)可見(jiàn)時(shí)會(huì)默認(rèn)給予應(yīng)用這個(gè)權(quán)限(實(shí)際這個(gè)懸浮窗權(quán)限開(kāi)關(guān)沒(méi)有打開(kāi)也行)慨畸,但是當(dāng)應(yīng)用處于后臺(tái)時(shí)只有這個(gè)懸浮穿權(quán)限開(kāi)關(guān)真的打開(kāi)時(shí)才能添加顯示懸浮窗莱坎!所以,只有真的打開(kāi)了懸浮窗權(quán)限開(kāi)關(guān)才算是達(dá)到了我們的目的4缡俊i苁病! 這么說(shuō)有點(diǎn)繞脖子弱卡,但是我相信你是懂我的乃正!
所以,對(duì)于Vivo的操作就顯得非常特別谐宙,重點(diǎn)在于確保App處于后臺(tái)時(shí)確實(shí)獲取到了懸浮窗權(quán)限烫葬!具體到Demo里面的代碼體現(xiàn)在:
private Handler mHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
int what = msg.what;
switch (what) {
case HANDLER_DETECT_PERMISSION:
if (FloatWindowParamManager.checkPermission(getApplicationContext())) {
//對(duì)沙雕VIVO機(jī)型特殊處理,應(yīng)用處于后臺(tái)檢查懸浮窗權(quán)限成功才能確認(rèn)真的獲取了懸浮窗權(quán)限
if (RomUtils.isVivoRom() && AppUtils.isAppForeground()) {
Log.e(TAG, "懸浮窗權(quán)限檢查成功,但App處于前臺(tái)狀態(tài),特殊機(jī)型會(huì)允許App獲取權(quán)限搭综,特殊機(jī)型就是指Vivo這個(gè)沙雕");
mHandler.sendEmptyMessageDelayed(HANDLER_DETECT_PERMISSION, 500);
return;
}
mHandler.removeMessages(HANDLER_DETECT_PERMISSION);
Log.e(TAG, "懸浮窗權(quán)限檢查成功");
showFloatPermissionWindow();
} else {
Log.e(TAG, "懸浮窗權(quán)限檢查失敗");
mHandler.sendEmptyMessageDelayed(HANDLER_DETECT_PERMISSION, 500);
}
break;
}
}
};
至于為什么要用Handler不停地循環(huán)檢查垢箕,這是為了在打開(kāi)權(quán)限后可以立刻顯示懸浮窗,具體效果看Demo就知道了兑巾。不過(guò)部分機(jī)型在打開(kāi)懸浮窗開(kāi)關(guān)的時(shí)候并不能立刻顯示懸浮窗条获,只有刷新頁(yè)面,比如點(diǎn)擊返回的時(shí)候才能顯示蒋歌。哎帅掘,我再次認(rèn)輸遼。如果哪位大佬有解決方案堂油,可以教一下我修档。
2019年2月20號(hào)新增9.0下懸浮窗不能進(jìn)入劉海區(qū)域的解決辦法:
//劉海屏延伸到劉海里面
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
layoutParams.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
}
如果不需要懸浮窗延伸到劉海里面去除這幾行代碼即可。
PS:華為Mate20劉海區(qū)域觸控?zé)o效府框,所以需要在該區(qū)域有觸控控件的需要避開(kāi)劉海區(qū)域吱窝。