1、懸浮窗的基本介紹
懸浮窗,大家應該也不陌生,凌駕於應用之上的一個小彈窗,實現上很簡單,就是新增一個系統級別的視窗,Android中通過WindowManagerService( WMS)來管理所有的視窗,對於WMS來說,管你是Activity、Toast、Dialog,都不過是通過WindowManagerGlobal.addView()新增的一個個View。 Android中的視窗分為三個級別:
- 1.1 應用視窗,比如Activity的視窗;
- 1.2 子視窗,依賴於父視窗,比如PopupWindow;
- 1.3 系統視窗,比如狀態列、Toast,目標懸浮窗就是系統視窗.
2、根據產品需求進行設計
先了解一下大概的產品需求: 1、懸浮窗需要跨越整個應用 2、需要與懸浮窗進行互動 3、懸浮窗得移動 4、點選跳轉特定的頁面 5、訊息提示的拖拽小紅點
需求很簡單,但是如果估算沒錯,不下一週產品經理會新增新的需求,所以為了更好的後續擴充套件,需要進行合理的設計,主要分為以下幾點: 1、懸浮窗自定義一個FrameLayout佈局FloatLayout,裡面進行拖動及點選響應處理; 2、FloatMonkService,是一個服務,開啟服務的時候建立懸浮窗; 3、FloatCallBack,互動介面,在FloatMonkService裡面實現介面,用於互動; 4、FloatWindowManager,懸浮窗的管理,因為後續懸浮窗佈局可能有好幾個,可以在這裡面進行切換; 5、HomeWatcherReceiver,廣播接收者,因為在應用內展示,需要監聽使用者在點選Home鍵和切換鍵的時候隱藏懸浮窗,需要FloatMonkService裡頭動態註冊; 6、FloatActionController,其實就是代理,其它模組需要通過它來和懸浮窗進行互動,真正幹活的是實現FloatCallBack介面的FloatMonkService; 7、FloatPermissionManager,需要適配各個傻逼機型的許可權,慶幸網上已有大佬分享,只需要單獨對7.0系統進行一些適配就行,懸浮窗許可權適配; 8、拖拽控制元件DraggableFlagView,直接拿來在懸浮窗上出現很奇怪的問題,所以需要改造一下下才能達到圖中效果。
3、具體實現
float_littlemonk_layout.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dfv="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/monk_relative_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/float_id"
android:layout_width="70dp"
android:layout_height="80dp"
android:layout_gravity="center_vertical|end"
android:scaleType="center"
android:src="@drawable/little_monk" />
</RelativeLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<floatwindow.xishuang.float_lib.view.DraggableFlagView
android:id="@+id/main_dfv"
android:layout_width="17dp"
android:layout_height="17dp"
android:layout_gravity="end"
dfv:color1="#FF3B30" />
</FrameLayout>
</FrameLayout>
複製程式碼
簡單的佈局,就是一張圖片+右上角放一個自定義的小紅點。
FloatLayout.java
@Override
public boolean onTouchEvent(MotionEvent event) {
// 獲取相對螢幕的座標,即以螢幕左上角為原點
int x = (int) event.getRawX();
int y = (int) event.getRawY();
//下面的這些事件,跟圖示的移動無關,為了區分開拖動和點選事件
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
startTime = System.currentTimeMillis();
mTouchStartX = event.getX();
mTouchStartY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
//圖示移動的邏輯在這裡
float mMoveStartX = event.getX();
float mMoveStartY = event.getY();
// 如果移動量大於3才移動
if (Math.abs(mTouchStartX - mMoveStartX) > 3
&& Math.abs(mTouchStartY - mMoveStartY) > 3) {
// 更新浮動視窗位置引數
mWmParams.x = (int) (x - mTouchStartX);
mWmParams.y = (int) (y - mTouchStartY);
mWindowManager.updateViewLayout(this, mWmParams);
return false;
}
break;
case MotionEvent.ACTION_UP:
endTime = System.currentTimeMillis();
//當從點選到彈起小於半秒的時候,則判斷為點選,如果超過則不響應點選事件
if ((endTime - startTime) > 0.1 * 1000L) {
isclick = false;
} else {
isclick = true;
}
break;
}
//響應點選事件
if (isclick) {
Toast.makeText(mContext, "我是大傻叼", Toast.LENGTH_SHORT).show();
}
return true;
}
複製程式碼
為了把懸浮窗的view操作抽離出來,自定義了這個佈局,主要進行兩部分功能,懸浮窗的移動和點選處理,重點是通過mWindowManager.updateViewLayout(this, mWmParams)來進行懸浮窗的位置移動,我這個Demo裡面只是簡單的通過時間來判斷點選事件,有必要的話點選事件需要新增特定View範圍判斷來響應點選。
// 如果移動量大於3才移動
if (Math.abs(mTouchStartX - mMoveStartX) > 3 && Math.abs(mTouchStartY - mMoveStartY) > 3)
複製程式碼
這個判斷是為了避免點選懸浮窗不在重心位置會出現移動的現象。
FloatMonkService.java
/**
* 懸浮窗在服務中建立,通過暴露介面FloatCallBack與Activity進行互動
*/
public class FloatMonkService extends Service implements FloatCallBack {
/**
* home鍵監聽
*/
private HomeWatcherReceiver mHomeKeyReceiver;
@Override
public void onCreate() {
super.onCreate();
FloatActionController.getInstance().registerCallLittleMonk(this);
//註冊廣播接收者
mHomeKeyReceiver = new HomeWatcherReceiver();
final IntentFilter homeFilter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
registerReceiver(mHomeKeyReceiver, homeFilter);
//初始化懸浮窗UI
initWindowData();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
/**
* 初始化WindowManager
*/
private void initWindowData() {
FloatWindowManager.createFloatWindow(this);
}
@Override
public void onDestroy() {
super.onDestroy();
//移除懸浮窗
FloatWindowManager.removeFloatWindowManager();
//登出廣播接收者
if (null != mHomeKeyReceiver) {
unregisterReceiver(mHomeKeyReceiver);
}
}
/////////////////////////////////////////////////////////實現介面////////////////////////////////////////////////////
@Override
public void guideUser(int type) {
FloatWindowManager.updataRedAndDialog(this);
}
/**
* 懸浮窗的隱藏
*/
@Override
public void hide() {
FloatWindowManager.hide();
}
/**
* 懸浮窗的顯示
*/
@Override
public void show() {
FloatWindowManager.show();
}
/**
* 新增可領取的數量
*/
@Override
public void addObtainNumer() {
FloatWindowManager.addObtainNumer(this);
guideUser(4);
}
/**
* 減少可領取的數量
*/
@Override
public void setObtainNumber(int number) {
FloatWindowManager.setObtainNumber(this, number);
}
}
複製程式碼
服務開啟的時候通過FloatWindowManager.createFloatWindow(this)來建立懸浮窗,實現FloatCallBack 實現需要互動的介面。下面看一下建立懸浮窗的真正操作是怎樣的。
FloatWindowManager.java
/**
* 建立一個小懸浮窗。初始位置為螢幕的右下角位置。
*/
public static void createFloatWindow(Context context) {
wmParams = new WindowManager.LayoutParams();
WindowManager windowManager = getWindowManager(context);
mFloatLayout = new FloatLayout(context);
if (Build.VERSION.SDK_INT >= 24) { /*android7.0不能用TYPE_TOAST*/
wmParams.type = WindowManager.LayoutParams.TYPE_PHONE;
} else { /*以下程式碼塊使得android6.0之後的使用者不必再去手動開啟懸浮窗許可權*/
String packname = context.getPackageName();
PackageManager pm = context.getPackageManager();
boolean permission = (PackageManager.PERMISSION_GRANTED == pm.checkPermission("android.permission.SYSTEM_ALERT_WINDOW", packname));
if (permission) {
wmParams.type = WindowManager.LayoutParams.TYPE_PHONE;
} else {
wmParams.type = WindowManager.LayoutParams.TYPE_TOAST;
}
}
//設定圖片格式,效果為背景透明
wmParams.format = PixelFormat.RGBA_8888;
//設定浮動視窗不可聚焦(實現操作除浮動視窗外的其他可見視窗的操作)
wmParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
//調整懸浮窗顯示的停靠位置為左側置頂
wmParams.gravity = Gravity.START | Gravity.TOP;
DisplayMetrics dm = new DisplayMetrics();
//取得視窗屬性
mWindowManager.getDefaultDisplay().getMetrics(dm);
//視窗的寬度
int screenWidth = dm.widthPixels;
//視窗高度
int screenHeight = dm.heightPixels;
//以螢幕左上角為原點,設定x、y初始值,相對於gravity
wmParams.x = screenWidth;
wmParams.y = screenHeight;
//設定懸浮視窗長寬資料
wmParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
mFloatLayout.setParams(wmParams);
windowManager.addView(mFloatLayout, wmParams);
mHasShown = true;
//是否展示小紅點展示
checkRedDot(context);
}
/**
* 返回當前已建立的WindowManager。
*/
private static WindowManager getWindowManager(Context context) {
if (mWindowManager == null) {
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
}
return mWindowManager;
}
複製程式碼
核心程式碼其實就是mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE),其中的context不能是Activity的,一開始就說了,Activity會返回它專享的WindowManager,而Activity的視窗級別是屬於應用層的。進行一些初始化操作之後 windowManager.addView(mFloatLayout, wmParams)把佈局新增進去就ok了。
if (Build.VERSION.SDK_INT >= 24) { /*android7.0不能用TYPE_TOAST*/
wmParams.type = WindowManager.LayoutParams.TYPE_PHONE;
} else { /*以下程式碼塊使得android6.0之後的使用者不必再去手動開啟懸浮窗許可權*/
String packname = context.getPackageName();
PackageManager pm = context.getPackageManager();
boolean permission = (PackageManager.PERMISSION_GRANTED == pm.checkPermission("android.permission.SYSTEM_ALERT_WINDOW", packname));
if (permission) {
wmParams.type = WindowManager.LayoutParams.TYPE_PHONE;
} else {
wmParams.type = WindowManager.LayoutParams.TYPE_TOAST;
}
}
複製程式碼
說一下這段程式碼的意義,當WindowManager.LayoutParams.type設定為WindowManager.LayoutParams.TYPE_TOAST的時候,是可以跳過許可權申請的,但是為毛又單獨適配各個機型呢,因為我們有小米Android系統,魅族Android系統,還有華為等等Android系統,特別是產品經理的魅族,一些特殊機型上是沒有效果的,所以為了更保險,得再加一份許可權申請,還有一點得提一下,那就是7.0上WindowManager.LayoutParams.TYPE_TOAST,懸浮窗只能持續一秒的時間,所以7.0不設這個type,谷歌爸爸最叼,7.0以上老老實實申請許可權。
FloatActionController.java
/**
* Author:xishuang
* Date:2017.08.01
* Des:與懸浮窗互動的控制類,真正的實現邏輯不在這
*/
public class FloatActionController {
private FloatActionController() {
}
public static FloatActionController getInstance() {
return LittleMonkProviderHolder.sInstance;
}
// 靜態內部類
private static class LittleMonkProviderHolder {
private static final FloatActionController sInstance = new FloatActionController();
}
private FloatCallBack mCallLittleMonk;
/**
* 開啟服務懸浮窗
*/
public void startMonkServer(Context context) {
Intent intent = new Intent(context, FloatMonkService.class);
context.startService(intent);
}
/**
* 關閉懸浮窗
*/
public void stopMonkServer(Context context) {
Intent intent = new Intent(context, FloatMonkService.class);
context.stopService(intent);
}
/**
* 註冊監聽
*/
public void registerCallLittleMonk(FloatCallBack callLittleMonk) {
mCallLittleMonk = callLittleMonk;
}
/**
* 懸浮窗的顯示
*/
public void show() {
if (mCallLittleMonk == null) return;
mCallLittleMonk.show();
}
/**
* 懸浮窗的隱藏
*/
public void hide() {
if (mCallLittleMonk == null) return;
mCallLittleMonk.hide();
}
}
複製程式碼
這就是暴露出來的介面,按需新增,效果大概是這樣的。
HomeWatcherReceiver.java
/**
* Author:xishuang
* Date:2017.08.01
* Des:一些Home建與切換鍵的廣播監聽,需要動態註冊
*/
public class HomeWatcherReceiver extends BroadcastReceiver {
private static final String TAG = "HomeWatcherReceiver";
private static final String SYSTEM_DIALOG_FROM_KEY = "reason";
private static final String SYSTEM_DIALOG_FROM_RECENT_APPS = "recentapps";
private static final String SYSTEM_DIALOG_FROM_HOME_KEY = "homekey";
private static final String SYSTEM_DIALOG_FROM_LOCK = "lock";
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
Log.i(TAG, "onReceive: action: " + action);
//根據不同的資訊進行一些個性操作
if (action.equals(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) {
String from = intent.getStringExtra(SYSTEM_DIALOG_FROM_KEY);
Log.i(TAG, "from: " + from);
if (SYSTEM_DIALOG_FROM_HOME_KEY.equals(from)) { //短按Home鍵
Log.i(TAG, "Home Key");
//按home鍵會直接關閉懸浮窗
FloatActionController.getInstance().stopMonkServer(context);
} else if (SYSTEM_DIALOG_FROM_RECENT_APPS.equals(from)) { //長按Home鍵或是Activity切換鍵
Log.i(TAG, "long press home key or activity switch");
} else if (SYSTEM_DIALOG_FROM_LOCK.equals(from)) { //鎖屏操作
Log.i(TAG, "lock");
}
}
}
}
複製程式碼
這個就是一個廣播接收者,需要監聽系統的一些操作,然後根據不同的操作實現自己想要的邏輯,Demo中我只是針對Home鍵進行了簡單的處理,點選Home退到主頁會直接銷燬服務,看具體要求進行擴充套件。
接下來看一下具體的使用,先看下Activity的佈局 activity_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:orientation="vertical"
tools:context="xishuang.floatwindow.MainActivity">
<Button
android:id="@+id/open_float"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="開啟懸浮窗" />
<Button
android:id="@+id/red_dot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="開啟小紅點" />
</LinearLayout>
複製程式碼
就是兩個按鈕,一個用來開啟懸浮窗,一個用來進行簡單的互動,展示小紅點。
Mainactivity.java
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button btOpenFloat = (Button) findViewById(R.id.open_float);
Button btRedDot = (Button) findViewById(R.id.red_dot);
assert btOpenFloat != null;
btOpenFloat.setOnClickListener(this);
assert btRedDot != null;
btRedDot.setOnClickListener(this);
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.open_float) {
boolean isPermission = FloatPermissionManager.getInstance().applyFloatWindow(this);
//有對應許可權或者系統版本小於7.0
if (isPermission || Build.VERSION.SDK_INT < 24) {
//開啟懸浮窗
FloatActionController.getInstance().startMonkServer(this);
}
} else if (v.getId() == R.id.red_dot) {
//開啟小紅點
FloatActionController.getInstance().setObtainNumber(1);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
//關閉懸浮窗
FloatActionController.getInstance().stopMonkServer(this);
}
}
複製程式碼
具體使用看起來也還簡單,因為邏輯都已經儘量封裝和解耦了,就是在開啟懸浮窗的時候,7.0版本以上必須先申請許可權才能開啟,7.0以下可以直接開啟,因為前面已經設定WindowManager.LayoutParams.TYPE_TOAST,雖然有些特殊機型也必須申請許可權,但起碼先保證我的懸浮窗在大多數手機上可以先展示出來。
boolean isPermission = FloatPermissionManager.getInstance().applyFloatWindow(this);
複製程式碼
這段程式碼說明,無論在哪種情況,我會先進行許可權檢查,雙重保險。
大概效果如下:
Demo:程式碼地址感興趣可以看看完整的演示程式碼。