應用浮窗由于良好的便捷性和拓展性,在某些場景下有著不錯的交互體驗。
恰巧項目需求有用到,可是逛了一圈GitHub,并沒有找到滿意的浮窗控件。
索性造個好用的輪子,方便你我他,遂成此文。
GitHub地址:EasyFloat
需求:我們想要什么
- 要能浮在某個單獨的頁面上,或者多個頁面上;
- 要支持拖拽,這樣才夠靈活;
- 可能需要吸附邊緣,也可能不需要吸附;
- 要支持浮窗內(nèi)部的點擊、拖拽;
- 要靈活的控制浮窗的顯示、隱藏、銷毀等;
- 要能夠自行設定出入動畫,這樣才夠炫酷、個性;
- 要能夠過濾不需要顯示的頁面;
- 要能夠指定位置、設置對齊方式和偏移量;
- 權限管理要簡單,能不需要最好;
- 要能有各個狀態(tài)的監(jiān)測、方便拓展;
- 還得使用方便、兼容性要強,要能在系統(tǒng)浮窗中使用輸入框;
- 反正想要的很多...
這么多需求,應該能滿足非極端使用場景了。可是這么多需求,我們需要如何一步步實現(xiàn)吶?
分析:假裝頭腦風暴
1,如何浮在其他視圖之上:
我們知道想要把View浮在其他視圖之上,有兩種實現(xiàn)方式:
- 將View添加到Activity的根布局,由于根布局是個FrameLayout,所以后添加的上層顯示;
- 創(chuàng)建Window窗口,直接將View添加到WindowManager中,這樣可以實現(xiàn)在所有的頁面顯示。
添加到Activity根布局相對比較簡單,也不需要額外的權限??墒亲畲蟮膯栴}是跟隨Activity生命周期,只能在當前Activity顯示。
Window窗口則能很好的解決全局顯示的問題,可是在Android 6.0之后(特殊機型除外),使用TYPE_APPLICATION_OVERLAY屬性,需要進行懸浮窗權限的申請,必須手動授權。如果我們只需要在當前頁面使用浮窗功能,又會覺得太重,使用不方便。
那我們改如何抉擇兩者?答案:都用,根據(jù)浮窗類型使用不同的創(chuàng)建方式。
2,怎么拖拽、怎么設置View:
既然要實現(xiàn)拖拽,肯定要從Touch事件下手,是單純的onTouchEvent重寫,還是要結合onInterceptTouchEvent作操作,我們后面再細說。但無論我們是以哪種方式創(chuàng)建的浮窗,都可以通過Touch事件實現(xiàn)拖拽效果,只是一些實現(xiàn)細節(jié)的不同。
既然說兩種浮窗的拖拽過程,有些許不同,那我們最好不要把自定義的拖拽View放在xml的根節(jié)點。因為那樣我們寫布局文件的時候,還需要進行區(qū)分;所以我們把拖拽View作為殼,放在浮窗控件的內(nèi)部,我們只需設置要展示的xml布局,然后將xml布局添加到拖拽殼里面,各司其職。
3,系統(tǒng)浮窗需要權限申請,權限如何處理:
既然是權限相關的操作,肯定包括下面三個部分:
- 懸浮窗權限的檢測;
- 有權限則直接創(chuàng)建,沒有權限則跳轉(zhuǎn)到權限授權頁;
- 根據(jù)授權結果,繼續(xù)創(chuàng)建浮窗或者回調(diào)創(chuàng)建失敗。
這些操作可以由開發(fā)人員一步步完成,但作為喜歡偷懶的我們,肯定希望輪子能夠自主完成這一切。但是我們應該怎么做吶?
由于權限申請,需要在onActivityResult處理授權結果,所以只能在Activity或者Fragment中進行。
作為一個合格的輪子,我們肯定不能選擇在Activity中操作;所以我們選擇在輪子內(nèi)部維護一個不可見的Fragment,進行權限的申請和授權結果的后續(xù)操作,在不需要的時候移除Fragment。
4,系統(tǒng)浮窗生命周期很長,如何創(chuàng)建、如何管理:
由于系統(tǒng)浮窗是作為全局使用的,生命周期很長。如果直接在Activity創(chuàng)建,當遇到Activity被銷毀時,這時的浮窗將是不可控的,滿足不了我們的需求啊。
怎么辦吶?首先我們想到是,通過一個管理者管理一個特定浮窗的所有事務,這樣我們只要擁有了這個管理者,就完成了對這個浮窗的掌控。可是這個管理者,應該存放在哪里?尤其是要生命周期足夠長。
答案就是,通過單例靜態(tài)類,管理所有的系統(tǒng)浮窗管理者。通過靜態(tài)容器存放具體的浮窗管理者,每個浮窗的Tag作為索引值,管理起來相當方便,數(shù)據(jù)也相當穩(wěn)健。
5,如果只要前臺顯示、或者有頁面不需要顯示怎么辦:
想要只在前臺顯示,我們首先要做的就是獲取前后臺的狀態(tài),這個應該怎么做吶?
我們可以通過ActivityLifecycleCallbacks感知各個Activity的生命周期,通過計算打開和關閉Activity的數(shù)目,就可以知道當前APP處于前臺還是后臺;然后根據(jù)前后臺發(fā)廣播控制浮窗顯示或者隱藏。
同理,有需要過濾的Activity,我們只需要監(jiān)聽它的生命周期變化,然后去控制顯示和隱藏就好了。
6,我們需要出入動畫,還不想每個都一樣:
學過策略模式的都應該知道,只要實現(xiàn)相應的接口或者復寫抽象方法,就可以去做你想要的結果。
我們把入場動畫、退場動畫的方法,定義在策略基類中;稍加操作,應有盡有...
分析過程就闡述這么多吧,這里進行了粗略的邏輯整理,我們一起看下:

說一千道一萬,還是圖片來的更直觀,那有沒有更直觀的吶?
還真有,我們一起看一下效果圖吧:
| 權限申請 | 系統(tǒng)浮窗 |
|---|---|
![]() |
![]() |
| 前臺和過濾 | 狀態(tài)回調(diào) |
|---|---|
![]() |
![]() |
| View修改 | 拓展使用 |
|---|---|
![]() |
![]() |
效果大致就是這個樣子,如果感興趣,我們一起看看是怎么實現(xiàn)的...
實施:那我們動手了
1,屬性管理:
工欲善其事,必先利其器。
既然浮窗屬性比較多,為了方便管理,我們建個屬性管理類,將各屬性放在一起,統(tǒng)一管理:
data class FloatConfig(
// 浮窗的xml布局文件
var layoutId: Int? = null,
// 當前浮窗的tag
var floatTag: String? = null,
// 是否可拖拽
var dragEnable: Boolean = true,
// 是否正在被拖拽
var isDrag: Boolean = false,
// 是否正在執(zhí)行動畫
var isAnim: Boolean = false,
// 是否顯示
var isShow: Boolean = false,
// 浮窗的吸附方式(默認不吸附,拖到哪里是哪里)
var sidePattern: SidePattern = SidePattern.DEFAULT,
// 浮窗顯示類型(默認只在當前頁顯示)
var showPattern: ShowPattern = ShowPattern.CURRENT_ACTIVITY,
// 寬高是否充滿父布局
var widthMatch: Boolean = false,
var heightMatch: Boolean = false,
// 浮窗的擺放方式,使用系統(tǒng)的Gravity屬性
var gravity: Int = 0,
// 坐標的偏移量
var offsetPair: Pair<Int,Int> = Pair(0,0),
// 固定的初始坐標,左上角坐標
var locationPair: Pair<Int, Int> = Pair(0, 0),
// ps:優(yōu)先使用固定坐標,若固定坐標不為原點坐標,gravity屬性和offset屬性無效
// Callbacks
var invokeView: OnInvokeView? = null,
var callbacks: OnFloatCallbacks? = null,
// 出入動畫
var floatAnimator: OnFloatAnimator? = DefaultAnimator(),
var appFloatAnimator: OnAppFloatAnimator? = AppFloatDefaultAnimator(),
// 不需要顯示系統(tǒng)浮窗的頁面集合,參數(shù)為類名
val filterSet: MutableSet<String> = mutableSetOf(),
// 是否需要顯示,當過濾信息匹配上時,該值為false
internal var needShow: Boolean = true
)
屬性都是一步步添加的,這里我們直接展示了最終的屬性列表。
為了使用方便,我們還為每個屬性設置了默認值,這樣即使不配什么參數(shù),也可以創(chuàng)建一個簡易的浮窗。
2,寫一個支持拖拽的普通控件:
前面我們有說過,拖拽功能在于重寫Touch事件。所以我們就寫一個自己的控件,繼承自ViewGroup,這里我們使用的是FrameLayout,然后重寫onTouchEvent方法:
override fun onTouchEvent(event: MotionEvent?): Boolean {
// updateView(event)是拖拽功能的具體實現(xiàn)
if (event != null) updateView(event)
// 如果是拖拽,這消費此事件,否則返回默認情況,防止影響子View事件的消費
return config.isDrag || super.onTouchEvent(event)
}
拖拽功能的實現(xiàn)思路就是:記錄ACTION_DOWN的坐標信息,在發(fā)生ACTION_MOVE的時候,計算兩者的差值,為View設置新的坐標;并且記錄更新后的坐標,為下次ACTION_MOVE提供新的基準。
private fun updateView(event: MotionEvent) {
// 關閉拖拽/執(zhí)行動畫階段,不可拖動
if (!config.dragEnable || config.isAnim) {
config.isDrag = false
isPressed = true
return
}
val rawX = event.rawX.toInt()
val rawY = event.rawY.toInt()
when (event.action and MotionEvent.ACTION_MASK) {
MotionEvent.ACTION_DOWN -> {
// 默認是點擊事件,而非拖拽事件
config.isDrag = false
isPressed = true
lastX = rawX
lastY = rawY
// 父布局不要攔截子布局的監(jiān)聽
parent.requestDisallowInterceptTouchEvent(true)
initParent()
}
MotionEvent.ACTION_MOVE -> {
// 只有父布局存在才可以拖動
if (parentHeight <= 0 || parentWidth <= 0) return
val dx = rawX - lastX
val dy = rawY - lastY
// 忽略過小的移動,防止點擊無效
if (!config.isDrag && dx * dx + dy * dy < 81) return
config.isDrag = true
var tempX = x + dx
var tempY = y + dy
// 檢測是否到達邊緣
tempX = when {
tempX < 0 -> 0f
tempX > parentWidth - width -> parentWidth - width.toFloat()
else -> tempX
}
tempY = when {
tempY < 0 -> 0f
tempY > parentHeight - height -> parentHeight - height.toFloat()
else -> tempY
}
// 更新位置
x = tempX
y = tempY
lastX = rawX
lastY = rawY
}
// 如果是拖動狀態(tài)下即非點擊按壓事件
MotionEvent.ACTION_UP -> isPressed = !config.isDrag
else -> return
}
}
由于項目支持多種吸附方式和回調(diào),真實情況比示例代碼復雜許多,但核心代碼如此。
這下拖拽效果是有的,可是在使用中發(fā)現(xiàn)了新的問題:如果子View有點擊事件,會導致該控件的拖拽失效。
這是由于安卓的Touch事件傳遞機制導致的,子View優(yōu)先享用Touch事件;默認情況下,只有在子View不消費事件的情況下,父控件才能夠接受到事件。
那我們有什么方法改變這一現(xiàn)狀吶?好在父控件存在攔截機制,使用onInterceptTouchEvent方法可以對Touch事件進行攔截,優(yōu)先使用Touch事件。
當返回值為true的時候,代表我們將事件進行了攔截,子View將不會在收到Touch事件,并且會調(diào)用當前控件的onTouchEvent方法。
所以我們需要在onTouchEvent方法和onInterceptTouchEvent方法都進行拖拽的邏輯處理,那么我們還需要加上下面這段代碼:
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
if (event != null) updateView(event)
// 是拖拽事件就進行攔截,反之不攔截
// ps:攔截后將不再回調(diào)該方法,所以后續(xù)事件需要在onTouchEvent中回調(diào)
return config.isDrag || super.onInterceptTouchEvent(event)
}
至此,我們解決了控件的拖拽問題,和子View的點擊問題。
拖拽控件不僅作為Activity浮窗的殼使用,也可以作為單獨的控件使用,直接在xml布局文件里包裹其他控件,就可以實現(xiàn)相應的拖拽效果。
系統(tǒng)浮窗的拖拽實現(xiàn)有些許的不同,主要是修改坐標的方式不同,核心思想也是一樣的。這里就不進行展示了,有需要的話,可以看一下相關代碼。
3,創(chuàng)建一個Activity浮窗:
Activity浮窗的創(chuàng)建相對簡單,可以歸納為下面三步:
- 拖拽效果由自定義的拖拽布局實現(xiàn);
- 將拖拽布局,添加到Activity的根布局;
- 再將浮窗的xml布局,添加到拖拽布局中,從而實現(xiàn)拖拽效果。
至于Activity根布局,就是屏幕底層FrameLayout,可通過DecorView進行獲取:
// 通過DecorView 獲取屏幕底層FrameLayout,即activity的根布局,作為浮窗的父布局
private var parentFrame: FrameLayout = activity.window.decorView.findViewById(android.R.id.content)
下面就是創(chuàng)建過程:
fun createFloat(config: FloatConfig) {
// 設置浮窗的拖拽外殼FloatingView
val floatingView = FloatingView(activity).apply {
// 為浮窗打上tag,如果未設置tag,使用類名作為tag
tag = getTag(config.floatTag)
// 默認wrap_content,會導致子view的match_parent無效,所以手動設置params
layoutParams = FrameLayout.LayoutParams(
if (config.widthMatch) FrameLayout.LayoutParams.MATCH_PARENT else FrameLayout.LayoutParams.WRAP_CONTENT,
if (config.heightMatch) FrameLayout.LayoutParams.MATCH_PARENT else FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
// 如若未設置固定坐標,設置浮窗Gravity
if (config.locationPair == Pair(0, 0)) gravity = config.gravity
}
// 同步配置
setFloatConfig(config)
}
// 將FloatingView添加到根布局中
parentFrame.addView(floatingView)
// 設置Callbacks
config.callbacks?.createdResult(true, null, floatingView)
config.floatCallbacks?.builder?.createdResult?.invoke(true, null, floatingView)
}
效果就是我們創(chuàng)建的View浮在當前Activity上了,而且可拖拽;結束當前Activity,浮窗也就不存在了。
4,創(chuàng)建一個系統(tǒng)浮窗:
這里我們主要看一下,如何把一個Window添加到WindowManager里面的。
由于創(chuàng)建一個Window有很多屬性需要設置,所以我們先來看一下相關參數(shù)的初始化:
private lateinit var windowManager: WindowManager
private lateinit var params: WindowManager.LayoutParams
private fun initParams() {
windowManager = context.getSystemService(Service.WINDOW_SERVICE) as WindowManager
params = WindowManager.LayoutParams().apply {
// 安卓6.0 以后,全局的Window類別,必須使用TYPE_APPLICATION_OVERLAY
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE
format = PixelFormat.RGBA_8888
gravity = Gravity.START or Gravity.TOP
// 設置浮窗以外的觸摸事件可以傳遞給后面的窗口、不自動獲取焦點、可以延伸到屏幕外(設置動畫時能用到,動畫結束需要去除該屬性,不然旋轉(zhuǎn)屏幕可能置于屏幕外部)
flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
width = if (config.widthMatch) WindowManager.LayoutParams.MATCH_PARENT else WindowManager.LayoutParams.WRAP_CONTENT
height = if (config.heightMatch) WindowManager.LayoutParams.MATCH_PARENT else WindowManager.LayoutParams.WRAP_CONTENT
// 如若設置了固定坐標,直接定位
if (config.locationPair != Pair(0, 0)) {
x = config.locationPair.first
y = config.locationPair.second
}
}
}
創(chuàng)建思路和Activity浮窗是一致的,只不過這次不是添加到Activity的根布局,而是直接添加到WindowManager:
private fun createAppFloat() {
// 創(chuàng)建一個frameLayout作為浮窗布局的父容器
frameLayout = ParentFrameLayout(context, config)
frameLayout?.tag = config.floatTag
// 將浮窗布局文件添加到父容器frameLayout中,并返回該浮窗文件
val floatingView = LayoutInflater.from(context.applicationContext)
.inflate(config.layoutId!!, frameLayout, true)
// 將frameLayout添加到系統(tǒng)windowManager中
windowManager.addView(frameLayout, params)
// 通過重寫frameLayout的Touch事件,實現(xiàn)拖拽效果
frameLayout?.touchListener = object : OnFloatTouchListener {
override fun onTouch(event: MotionEvent) =
touchUtils.updateFloat(frameLayout!!, event, windowManager, params)
}
...
// 設置入場動畫、設置Callbacks
}
5,通過靜態(tài)集合管理所有的系統(tǒng)浮窗:
internal object FloatManager {
private const val DEFAULT_TAG = "default"
val floatMap = mutableMapOf<String, AppFloatManager>()
/**
* 創(chuàng)建系統(tǒng)浮窗,首先檢查浮窗是否存在:不存在則創(chuàng)建,存在則回調(diào)提示
*/
fun create(context: Context, config: FloatConfig) = if (checkTag(config)) {
// 通過floatManager創(chuàng)建浮窗,并將floatManager添加到map中
floatMap[config.floatTag!!] = AppFloatManager(context.applicationContext, config)
.apply { createFloat() }
} else {
config.callbacks?.createdResult(false, "請為系統(tǒng)浮窗設置不同的tag", null)
logger.w("請為系統(tǒng)浮窗設置不同的tag")
}
/**
* 設置浮窗的顯隱,用戶主動調(diào)用隱藏時,needShow需要為false
*/
fun visible(isShow: Boolean, tag: String? = null, needShow: Boolean = true) =
floatMap[getTag(tag)]?.setVisible(if (isShow) View.VISIBLE else View.GONE, needShow)
/**
* 關閉浮窗,執(zhí)行浮窗的退出動畫
*/
fun dismiss(tag: String? = null) = floatMap[getTag(tag)]?.exitAnim()
/**
* 移除當條浮窗信息,在退出完成后調(diào)用
*/
fun remove(floatTag: String?) = floatMap.remove(floatTag)
/**
* 獲取浮窗tag,為空則使用默認值
*/
fun getTag(tag: String?) = tag ?: DEFAULT_TAG
/**
* 獲取具體的系統(tǒng)浮窗管理類
*/
fun getAppFloatManager(tag: String?) = floatMap[getTag(tag)]
/**
* 檢測浮窗的tag是否有效,不同的浮窗必須設置不同的tag
*/
private fun checkTag(config: FloatConfig): Boolean {
// 如果未設置tag,設置默認tag
config.floatTag = getTag(config.floatTag)
return !floatMap.containsKey(config.floatTag!!)
}
}
系統(tǒng)的浮窗的所有管理皆通過此類,全部代碼也只有這么多,畢竟它只是起到了中轉(zhuǎn)和統(tǒng)一管理的作用;具體的系統(tǒng)浮窗功能,還是交由AppFloatManager來實現(xiàn)的。
6,系統(tǒng)浮窗創(chuàng)建前的權限管理:
即使是系統(tǒng)浮窗,安卓6.0之前也是不需要權限申請的,但這只是存在理想的情況下。由于安卓的碎片化嚴重,尤其神一樣的國產(chǎn)手機面前,適配坑,權限適配神坑。
個人能力有限,遇到這種情況只好選擇站著前人的肩膀上,Android 懸浮窗權限各機型各系統(tǒng)適配大全,這篇文章的解決方案還是比較全面的。所以本文的權限適配使用的此方案,但是該方案只具有適配性,不具有自主性。
為了提高自主性,我們先進行權限檢測;如果發(fā)現(xiàn)沒有授權,我們通過Fragment進行浮窗權限的申請。這樣授權結果就不需要寫在我們自己的Activity,直接在Fragment內(nèi)部進行,并且通過接口授權結果告訴外部。
其實所謂的外部,也就是我們的Builder構建類。在我們的構建類拿到授權結果以后,根據(jù)授權情況選擇繼續(xù)創(chuàng)建浮窗,或者回調(diào)創(chuàng)建失敗。
internal class PermissionFragment : Fragment() {
companion object {
private var onPermissionResult: OnPermissionResult? = null
@SuppressLint("CommitTransaction")
fun requestPermission(activity: Activity, onPermissionResult: OnPermissionResult) {
this.onPermissionResult = onPermissionResult
activity.fragmentManager
.beginTransaction()
.add(PermissionFragment(), activity.localClassName)
.commitAllowingStateLoss()
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// 權限申請
PermissionUtils.requestPermission(this)
logger.i("PermissionFragment:requestPermission")
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionUtils.requestCode) {
// 需要延遲執(zhí)行,不然即使授權,仍有部分機型獲取不到權限
Handler(Looper.getMainLooper()).postDelayed({
val check = PermissionUtils.checkPermission(activity)
logger.i("PermissionFragment onActivityResult: $check")
// 回調(diào)權限結果
onPermissionResult?.permissionResult(check)
// 將Fragment移除
fragmentManager.beginTransaction().remove(this).commitAllowingStateLoss()
}, 500)
}
}
}
由于在構建類調(diào)用的權限申請,使用在此處需要實現(xiàn)OnPermissionResult接口:
// 懸浮窗權限的申請結果
override fun permissionResult(isOpen: Boolean) {
if (isOpen) createAppFloat()
else config.callbacks?.createdResult(false, "系統(tǒng)浮窗權限不足,開啟失敗", null)
}
7,設置出入動畫:
說出入動畫前,我們先回顧下策略模式:定義一系列的算法,把每一個算法封裝起來,并且使它們可相互替換。策略模式使得算法可獨立于使用它的客戶而獨立變化。
- 定義了一族算法(業(yè)務規(guī)則);
- 封裝了每個算法;
- 這族的算法可互換代替(interchangeable)。
上述三點摘抄自維基百科,簡單說就是可以通過不同的實現(xiàn)過程,給出想要的實現(xiàn)結果。
如:某接口或某抽象類,包含排序算法,至于我們怎么排序:使用冒牌排序、快速排序,還是其他的排序都是可以的。

接下來我們一起看輪子中的策略實例,由于Activity浮窗和系統(tǒng)浮窗的創(chuàng)建方式不同,動畫實現(xiàn)也有些許不同。但流程相同,這里以Activity浮窗動畫作為展示。
- 首先我們定義一個抽象策略基類,動畫接口:
interface OnFloatAnimator {
// 入場動畫
fun enterAnim(view: View, parentView: ViewGroup, sidePattern: SidePattern): Animator? = null
// 退出動畫
fun exitAnim(view: View, parentView: ViewGroup, sidePattern: SidePattern): Animator? = null
}
- 創(chuàng)建具體策略類,也就是默認動畫實現(xiàn)類:
open class DefaultAnimator : OnFloatAnimator {
// 浮窗各邊到窗口邊框的距離
private var leftDistance = 0
private var rightDistance = 0
private var topDistance = 0
private var bottomDistance = 0
// x軸和y軸距離的最小值
private var minX = 0
private var minY = 0
// 浮窗和窗口所在的矩形
private var floatRect = Rect()
private var parentRect = Rect()
// 實現(xiàn)接口中的入場動畫,exitAnim()類似,此處省略了
override fun enterAnim(
view: View,
parentView: ViewGroup,
sidePattern: SidePattern
): Animator? {
initValue(view, parentView)
val (animType, startValue, endValue) = animTriple(view, sidePattern)
return ObjectAnimator.ofFloat(view, animType, startValue, endValue).setDuration(500)
}
... // 退出動畫
/**
* 設置動畫類型,計算具體數(shù)值
*/
private fun animTriple(view: View, sidePattern: SidePattern): Triple<String, Float, Float> {
val animType: String
val startValue: Float = when (sidePattern) {
SidePattern.LEFT, SidePattern.RESULT_LEFT -> {
animType = "translationX"
leftValue(view)
}
... // 不同的吸附模式,不同的出入方式
else -> {
if (minX <= minY) {
animType = "translationX"
if (leftDistance < rightDistance) leftValue(view) else rightValue(view)
} else {
animType = "translationY"
if (topDistance < bottomDistance) topValue(view) else bottomValue(view)
}
}
}
val endValue = if (animType == "translationX") view.translationX else view.translationY
return Triple(animType, startValue, endValue)
}
private fun leftValue(view: View) = -(leftDistance + view.width) + view.translationX
private fun rightValue(view: View) = rightDistance + view.width + view.translationX
private fun topValue(view: View) = -(topDistance + view.height) + view.translationY
private fun bottomValue(view: View) = bottomDistance + view.height + view.translationY
/**
* 計算一些數(shù)值,方便使用
*/
private fun initValue(view: View, parentView: ViewGroup) {
view.getGlobalVisibleRect(floatRect)
parentView.getGlobalVisibleRect(parentRect)
leftDistance = floatRect.left
rightDistance = parentRect.right - floatRect.right
topDistance = floatRect.top - parentRect.top
bottomDistance = parentRect.bottom - floatRect.bottom
minX = min(leftDistance, rightDistance)
minY = min(topDistance, bottomDistance)
}
}
- 創(chuàng)建環(huán)境類,也就是動畫管理類:
internal class AnimatorManager(
private val onFloatAnimator: OnFloatAnimator?,
private val view: View,
private val parentView: ViewGroup,
private val sidePattern: SidePattern
) {
// 通過接口實現(xiàn)具體動畫,所以只需要更改接口的具體實現(xiàn)
fun enterAnim(): Animator? = onFloatAnimator?.enterAnim(view, parentView, sidePattern)
fun exitAnim(): Animator? = onFloatAnimator?.exitAnim(view, parentView, sidePattern)
}
準備工作都準備妥當了,那我們在哪里調(diào)用動畫吶?
入場動畫:肯定是在浮窗創(chuàng)建完成的時候調(diào)用,所以我們在拖拽控件的onLayout方法里調(diào)用入場動畫。不過有個細節(jié)要注意,只有在第一次執(zhí)行onLayout方法時才調(diào)用入場動畫,因為隱藏再顯示,也是會調(diào)用onLayout方法的。
退出動畫:則在我們調(diào)用關閉浮窗時調(diào)用。如果退出動畫不為空,先執(zhí)行動畫,動畫結束的時候銷毀浮窗控件;如果退出動畫為空,則直接銷毀浮窗。
- 動畫的使用,以退出動畫為例:
internal fun exitAnim() {
// 正在執(zhí)行動畫,防止重復調(diào)用
if (config.isAnim) return
val manager: AnimatorManager? = AnimatorManager(config.floatAnimator, this, parentView, config.sidePattern)
val animator: Animator? = manager?.exitAnim()
if (animator == null) {
config.callbacks?.dismiss()
parentView.removeView(this@AbstractDragFloatingView)
} else {
animator.addListener(object : Animator.AnimatorListener {
override fun onAnimationEnd(animation: Animator?) {
config.isAnim = false
config.callbacks?.dismiss()
parentView.removeView(this@AbstractDragFloatingView)
}
override fun onAnimationStart(animation: Animator?) {
config.isAnim = true
}
...
})
animator.start()
}
}
看得出來,我們內(nèi)部做了動畫的監(jiān)聽和執(zhí)行,config.floatAnimator就是我們外部傳入的動畫實現(xiàn)類。
動畫類型也沒有做過多限制,使用的是動畫的超類Animator,所以視圖動畫和屬性動畫都是可以的;不需要動畫直接在實現(xiàn)類里返回null即可。
8,頁面過濾和僅前臺顯示:
前面我們說屬性管理的時候,在FloatConfig數(shù)據(jù)類里,有下面這個屬性:
// 不需要顯示系統(tǒng)浮窗的頁面集合,參數(shù)為類名
val filterSet: MutableSet<String> = mutableSetOf()
這個頁面過濾集合,可以在創(chuàng)建浮窗的時候就設置,也可以在需要的時候進行設置。集合數(shù)據(jù)好管理,主要是過濾功能是如何實現(xiàn)的。
在Application類中,ActivityLifecycleCallbacks可以實現(xiàn)各個Activity的生命周期監(jiān)控,我們只要在特定的Activity顯示時控制浮窗隱藏,在Activity不顯示時再重新讓浮窗顯示。
同理,如果讓浮窗實現(xiàn)僅前臺顯示,也可以使用此方式,當所有的Activity都不顯示的時候,浮窗隱藏,反正浮窗重新顯示。
internal object LifecycleUtils {
private var activityCount = 0
private lateinit var application: Application
fun setLifecycleCallbacks(application: Application) {
this.application = application
application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity?) {
if (activity == null) return
activityCount++
FloatManager.floatMap.forEach { (tag, manager) ->
run {
// 如果手動隱藏浮窗,不再考慮過濾信息
if (!manager.config.needShow) return@run
// 過濾不需要顯示浮窗的頁面
manager.config.filterSet.forEach filterSet@{
if (it == activity.componentName.className) {
setVisible(false, tag)
manager.config.needShow = false
logger.i("過濾浮窗顯示: $it, tag: $tag")
return@filterSet
}
}
// 當過濾信息沒有匹配上時,需要發(fā)送廣播,反之修改needShow為默認值
if (manager.config.needShow) setVisible(tag = tag)
else manager.config.needShow = true
}
}
}
override fun onActivityStopped(activity: Activity?) {
if (activity == null) return
activityCount--
if (isForeground()) return
// 當app處于后臺時,檢測是否有僅前臺顯示的系統(tǒng)浮窗
FloatManager.floatMap.forEach { (tag, manager) ->
run {
// 如果手動隱藏浮窗,不再考慮過濾信息
if (!manager.config.needShow) return@run
when (manager.config.showPattern) {
ShowPattern.ALL_TIME -> setVisible(true, tag)
ShowPattern.FOREGROUND -> setVisible(tag = tag)
else -> return
}
}
}
}
... // 其他的生命周期回調(diào)
})
}
private fun isForeground() = activityCount > 0
private fun setVisible(boolean: Boolean = isForeground(), tag: String?) = FloatManager.visible(boolean, tag)
}
不過使用該生命周期監(jiān)控,需要我們傳入Application,即在項目的Application中需要進行浮窗的初始化;如果沒使用到過濾和僅前臺顯示,則不需要。
實施階段也就說這么多吧,其他一些點和一些注意細節(jié),都在代碼中,感興趣的可以去看下。
使用:上手體驗
說了這么多,到底好不好用吶?我們寫個最簡單的浮窗:
EasyFloat.with(this).setLayout(R.layout.float_test).show()
對,沒有看錯,一行代碼就可以創(chuàng)建一個拖拽浮窗,默認只在當頁顯示。
作為結束,我們從上圖中挑一個來實現(xiàn)。由于浮窗只支持拖拽,不支持縮放,那我們就選那個支持縮放的系統(tǒng)浮窗吧:

上圖中一共包含了這幾個屬性:設置僅前臺顯示、過濾SecondActivity、固定坐標、取消出入動畫、點擊關閉、拖拽縮放。
private fun showAppFloat(tag: String) {
EasyFloat.with(this)
.setLayout(R.layout.float_app_scale)
.setTag(tag)
.setShowPattern(ShowPattern.FOREGROUND)
.setLocation(100, 100)
.setAppFloatAnimator(null)
.setFilter(SecondActivity::class.java)
.invokeView(OnInvokeView {
val content = it.findViewById<RelativeLayout>(R.id.rlContent)
val params = content.layoutParams as FrameLayout.LayoutParams
it.findViewById<ScaleImage>(R.id.ivScale).onScaledListener = object : ScaleImage.OnScaledListener {
override fun onScaled(x: Float, y: Float, event: MotionEvent) {
params.width += x.toInt()
params.height += y.toInt()
content.layoutParams = params
}
}
it.findViewById<ImageView>(R.id.ivClose).setOnClickListener {
EasyFloat.dismissAppFloat(tag)
}
})
.show()
}
需要指出的是,這里的拖拽縮放不包含在輪子中,在示例代碼里。我們一塊看下是怎么實現(xiàn)的,如有需要參考示例:
class ScaleImage(context: Context, attrs: AttributeSet? = null) : ImageView(context, attrs) {
private var touchDownX = 0f
private var touchDownY = 0f
var onScaledListener: OnScaledListener? = null
interface OnScaledListener {
fun onScaled(x: Float, y: Float, event: MotionEvent)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (event == null) return super.onTouchEvent(event)
// 屏蔽掉浮窗的事件攔截,僅由自身消費
parent?.requestDisallowInterceptTouchEvent(true)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
touchDownX = event.x
touchDownY = event.y
}
MotionEvent.ACTION_MOVE ->
onScaledListener?.onScaled(event.x - touchDownX, event.y - touchDownY, event)
}
return true
}
}
邏輯很簡單,只是記錄手指相對于按下時的滑動距離,外部根據(jù)這個距離差值,從新設置控件大小。關鍵一點要屏蔽掉浮窗的事件攔截,不然接收不到觸摸事件。
文章到這里就已經(jīng)全部結束了,非常感謝大家的閱讀。
輪子已上傳到GitHub,希望對大家有所幫助,如果能收獲個Star,那也最開心不過了。
項目地址:https://github.com/princekin-f/EasyFloat
特別感謝:Android 懸浮窗權限各機型各系統(tǒng)適配大全
說在后面:
系統(tǒng)浮窗的管理原先使用的是Service,坑神多!借鑒別人的同時,也應保持質(zhì)疑和思考……




