應(yīng)用浮窗由于良好的便捷性和拓展性,在某些場景下有著不錯(cuò)的交互體驗(yàn)馋贤。
恰巧項(xiàng)目需求有用到,可是逛了一圈GitHub畏陕,并沒有找到滿意的浮窗控件配乓。
索性造個(gè)好用的輪子,方便你我他惠毁,遂成此文犹芹。
GitHub地址:EasyFloat
需求:我們想要什么
- 要能浮在某個(gè)單獨(dú)的頁面上,或者多個(gè)頁面上鞠绰;
- 要支持拖拽腰埂,這樣才夠靈活;
- 可能需要吸附邊緣蜈膨,也可能不需要吸附屿笼;
- 要支持浮窗內(nèi)部的點(diǎn)擊、拖拽翁巍;
- 要靈活的控制浮窗的顯示驴一、隱藏、銷毀等灶壶;
- 要能夠自行設(shè)定出入動(dòng)畫肝断,這樣才夠炫酷、個(gè)性;
- 要能夠過濾不需要顯示的頁面胸懈;
- 要能夠指定位置担扑、設(shè)置對齊方式和偏移量;
- 權(quán)限管理要簡單趣钱,能不需要最好涌献;
- 要能有各個(gè)狀態(tài)的監(jiān)測、方便拓展羔挡;
- 還得使用方便洁奈、兼容性要強(qiáng),要能在系統(tǒng)浮窗中使用輸入框绞灼;
- 反正想要的很多...
這么多需求利术,應(yīng)該能滿足非極端使用場景了〉桶可是這么多需求印叁,我們需要如何一步步實(shí)現(xiàn)吶?
分析:假裝頭腦風(fēng)暴
1军掂,如何浮在其他視圖之上:
我們知道想要把View浮在其他視圖之上轮蜕,有兩種實(shí)現(xiàn)方式:
- 將View添加到Activity的根布局,由于根布局是個(gè)FrameLayout蝗锥,所以后添加的上層顯示跃洛;
- 創(chuàng)建Window窗口,直接將View添加到WindowManager中终议,這樣可以實(shí)現(xiàn)在所有的頁面顯示汇竭。
添加到Activity根布局相對比較簡單,也不需要額外的權(quán)限穴张∠噶牵可是最大的問題是跟隨Activity生命周期,只能在當(dāng)前Activity顯示皂甘。
Window窗口則能很好的解決全局顯示的問題玻驻,可是在Android 6.0之后(特殊機(jī)型除外),使用TYPE_APPLICATION_OVERLAY
屬性偿枕,需要進(jìn)行懸浮窗權(quán)限的申請璧瞬,必須手動(dòng)授權(quán)。如果我們只需要在當(dāng)前頁面使用浮窗功能渐夸,又會(huì)覺得太重嗤锉,使用不方便。
那我們改如何抉擇兩者捺萌?答案:都用,根據(jù)浮窗類型使用不同的創(chuàng)建方式。
2桃纯,怎么拖拽酷誓、怎么設(shè)置View:
既然要實(shí)現(xiàn)拖拽,肯定要從Touch事件下手态坦,是單純的onTouchEvent
重寫盐数,還是要結(jié)合onInterceptTouchEvent
作操作,我們后面再細(xì)說伞梯。但無論我們是以哪種方式創(chuàng)建的浮窗玫氢,都可以通過Touch事件實(shí)現(xiàn)拖拽效果,只是一些實(shí)現(xiàn)細(xì)節(jié)的不同谜诫。
既然說兩種浮窗的拖拽過程漾峡,有些許不同,那我們最好不要把自定義的拖拽View放在xml的根節(jié)點(diǎn)喻旷。因?yàn)槟菢游覀儗懖季治募臅r(shí)候生逸,還需要進(jìn)行區(qū)分;所以我們把拖拽View作為殼且预,放在浮窗控件的內(nèi)部槽袄,我們只需設(shè)置要展示的xml布局,然后將xml布局添加到拖拽殼里面锋谐,各司其職遍尺。
3,系統(tǒng)浮窗需要權(quán)限申請涮拗,權(quán)限如何處理:
既然是權(quán)限相關(guān)的操作乾戏,肯定包括下面三個(gè)部分:
- 懸浮窗權(quán)限的檢測;
- 有權(quán)限則直接創(chuàng)建多搀,沒有權(quán)限則跳轉(zhuǎn)到權(quán)限授權(quán)頁歧蕉;
- 根據(jù)授權(quán)結(jié)果托嚣,繼續(xù)創(chuàng)建浮窗或者回調(diào)創(chuàng)建失敗肩祥。
這些操作可以由開發(fā)人員一步步完成凤覆,但作為喜歡偷懶的我們读整,肯定希望輪子能夠自主完成這一切棚菊。但是我們應(yīng)該怎么做吶罪塔?
由于權(quán)限申請若厚,需要在onActivityResult
處理授權(quán)結(jié)果惑艇,所以只能在Activity或者Fragment中進(jìn)行夷野。
作為一個(gè)合格的輪子懊蒸,我們肯定不能選擇在Activity中操作;所以我們選擇在輪子內(nèi)部維護(hù)一個(gè)不可見的Fragment悯搔,進(jìn)行權(quán)限的申請和授權(quán)結(jié)果的后續(xù)操作骑丸,在不需要的時(shí)候移除Fragment。
4,系統(tǒng)浮窗生命周期很長通危,如何創(chuàng)建铸豁、如何管理:
由于系統(tǒng)浮窗是作為全局使用的,生命周期很長菊碟。如果直接在Activity創(chuàng)建节芥,當(dāng)遇到Activity被銷毀時(shí),這時(shí)的浮窗將是不可控的逆害,滿足不了我們的需求啊头镊。
怎么辦吶?首先我們想到是魄幕,通過一個(gè)管理者管理一個(gè)特定浮窗的所有事務(wù)相艇,這樣我們只要擁有了這個(gè)管理者,就完成了對這個(gè)浮窗的掌控梅垄。可是這個(gè)管理者厂捞,應(yīng)該存放在哪里?尤其是要生命周期足夠長队丝。
答案就是靡馁,通過單例靜態(tài)類,管理所有的系統(tǒng)浮窗管理者机久。通過靜態(tài)容器存放具體的浮窗管理者臭墨,每個(gè)浮窗的Tag作為索引值,管理起來相當(dāng)方便膘盖,數(shù)據(jù)也相當(dāng)穩(wěn)健胧弛。
5,如果只要前臺(tái)顯示侠畔、或者有頁面不需要顯示怎么辦:
想要只在前臺(tái)顯示结缚,我們首先要做的就是獲取前后臺(tái)的狀態(tài),這個(gè)應(yīng)該怎么做吶软棺?
我們可以通過ActivityLifecycleCallbacks
感知各個(gè)Activity的生命周期红竭,通過計(jì)算打開和關(guān)閉Activity的數(shù)目,就可以知道當(dāng)前APP處于前臺(tái)還是后臺(tái)喘落;然后根據(jù)前后臺(tái)發(fā)廣播控制浮窗顯示或者隱藏茵宪。
同理,有需要過濾的Activity瘦棋,我們只需要監(jiān)聽它的生命周期變化稀火,然后去控制顯示和隱藏就好了。
6赌朋,我們需要出入動(dòng)畫凰狞,還不想每個(gè)都一樣:
學(xué)過策略模式的都應(yīng)該知道篇裁,只要實(shí)現(xiàn)相應(yīng)的接口或者復(fù)寫抽象方法,就可以去做你想要的結(jié)果赡若。
我們把入場動(dòng)畫茴恰、退場動(dòng)畫的方法,定義在策略基類中斩熊;稍加操作,應(yīng)有盡有...
分析過程就闡述這么多吧伐庭,這里進(jìn)行了粗略的邏輯整理粉渠,我們一起看下:
說一千道一萬,還是圖片來的更直觀圾另,那有沒有更直觀的吶霸株?
還真有,我們一起看一下效果圖吧:
權(quán)限申請 | 系統(tǒng)浮窗 |
---|---|
前臺(tái)和過濾 | 狀態(tài)回調(diào) |
---|---|
View修改 | 拓展使用 |
---|---|
效果大致就是這個(gè)樣子集乔,如果感興趣去件,我們一起看看是怎么實(shí)現(xiàn)的...
實(shí)施:那我們動(dòng)手了
1,屬性管理:
工欲善其事扰路,必先利其器尤溜。
既然浮窗屬性比較多,為了方便管理汗唱,我們建個(gè)屬性管理類宫莱,將各屬性放在一起,統(tǒng)一管理:
data class FloatConfig(
// 浮窗的xml布局文件
var layoutId: Int? = null,
// 當(dāng)前浮窗的tag
var floatTag: String? = null,
// 是否可拖拽
var dragEnable: Boolean = true,
// 是否正在被拖拽
var isDrag: Boolean = false,
// 是否正在執(zhí)行動(dòng)畫
var isAnim: Boolean = false,
// 是否顯示
var isShow: Boolean = false,
// 浮窗的吸附方式(默認(rèn)不吸附哩罪,拖到哪里是哪里)
var sidePattern: SidePattern = SidePattern.DEFAULT,
// 浮窗顯示類型(默認(rèn)只在當(dāng)前頁顯示)
var showPattern: ShowPattern = ShowPattern.CURRENT_ACTIVITY,
// 寬高是否充滿父布局
var widthMatch: Boolean = false,
var heightMatch: Boolean = false,
// 浮窗的擺放方式授霸,使用系統(tǒng)的Gravity屬性
var gravity: Int = 0,
// 坐標(biāo)的偏移量
var offsetPair: Pair<Int,Int> = Pair(0,0),
// 固定的初始坐標(biāo),左上角坐標(biāo)
var locationPair: Pair<Int, Int> = Pair(0, 0),
// ps:優(yōu)先使用固定坐標(biāo)际插,若固定坐標(biāo)不為原點(diǎn)坐標(biāo)碘耳,gravity屬性和offset屬性無效
// Callbacks
var invokeView: OnInvokeView? = null,
var callbacks: OnFloatCallbacks? = null,
// 出入動(dòng)畫
var floatAnimator: OnFloatAnimator? = DefaultAnimator(),
var appFloatAnimator: OnAppFloatAnimator? = AppFloatDefaultAnimator(),
// 不需要顯示系統(tǒng)浮窗的頁面集合,參數(shù)為類名
val filterSet: MutableSet<String> = mutableSetOf(),
// 是否需要顯示框弛,當(dāng)過濾信息匹配上時(shí)辛辨,該值為false
internal var needShow: Boolean = true
)
屬性都是一步步添加的,這里我們直接展示了最終的屬性列表功咒。
為了使用方便愉阎,我們還為每個(gè)屬性設(shè)置了默認(rèn)值,這樣即使不配什么參數(shù)力奋,也可以創(chuàng)建一個(gè)簡易的浮窗榜旦。
2,寫一個(gè)支持拖拽的普通控件:
前面我們有說過景殷,拖拽功能在于重寫Touch事件溅呢。所以我們就寫一個(gè)自己的控件澡屡,繼承自ViewGroup
,這里我們使用的是FrameLayout
咐旧,然后重寫onTouchEvent
方法:
override fun onTouchEvent(event: MotionEvent?): Boolean {
// updateView(event)是拖拽功能的具體實(shí)現(xiàn)
if (event != null) updateView(event)
// 如果是拖拽驶鹉,這消費(fèi)此事件,否則返回默認(rèn)情況铣墨,防止影響子View事件的消費(fèi)
return config.isDrag || super.onTouchEvent(event)
}
拖拽功能的實(shí)現(xiàn)思路就是:記錄ACTION_DOWN
的坐標(biāo)信息室埋,在發(fā)生ACTION_MOVE
的時(shí)候,計(jì)算兩者的差值伊约,為View設(shè)置新的坐標(biāo)姚淆;并且記錄更新后的坐標(biāo),為下次ACTION_MOVE
提供新的基準(zhǔn)屡律。
private fun updateView(event: MotionEvent) {
// 關(guān)閉拖拽/執(zhí)行動(dòng)畫階段腌逢,不可拖動(dòng)
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 -> {
// 默認(rèn)是點(diǎn)擊事件,而非拖拽事件
config.isDrag = false
isPressed = true
lastX = rawX
lastY = rawY
// 父布局不要攔截子布局的監(jiān)聽
parent.requestDisallowInterceptTouchEvent(true)
initParent()
}
MotionEvent.ACTION_MOVE -> {
// 只有父布局存在才可以拖動(dòng)
if (parentHeight <= 0 || parentWidth <= 0) return
val dx = rawX - lastX
val dy = rawY - lastY
// 忽略過小的移動(dòng)超埋,防止點(diǎn)擊無效
if (!config.isDrag && dx * dx + dy * dy < 81) return
config.isDrag = true
var tempX = x + dx
var tempY = y + dy
// 檢測是否到達(dá)邊緣
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
}
// 如果是拖動(dòng)狀態(tài)下即非點(diǎn)擊按壓事件
MotionEvent.ACTION_UP -> isPressed = !config.isDrag
else -> return
}
}
由于項(xiàng)目支持多種吸附方式和回調(diào)搏讶,真實(shí)情況比示例代碼復(fù)雜許多,但核心代碼如此霍殴。
這下拖拽效果是有的媒惕,可是在使用中發(fā)現(xiàn)了新的問題:如果子View有點(diǎn)擊事件,會(huì)導(dǎo)致該控件的拖拽失效来庭。
這是由于安卓的Touch事件傳遞機(jī)制導(dǎo)致的吓笙,子View優(yōu)先享用Touch事件;默認(rèn)情況下巾腕,只有在子View不消費(fèi)事件的情況下面睛,父控件才能夠接受到事件。
那我們有什么方法改變這一現(xiàn)狀吶尊搬?好在父控件存在攔截機(jī)制叁鉴,使用onInterceptTouchEvent
方法可以對Touch事件進(jìn)行攔截,優(yōu)先使用Touch事件佛寿。
當(dāng)返回值為true的時(shí)候幌墓,代表我們將事件進(jìn)行了攔截,子View將不會(huì)在收到Touch事件冀泻,并且會(huì)調(diào)用當(dāng)前控件的onTouchEvent
方法常侣。
所以我們需要在onTouchEvent
方法和onInterceptTouchEvent
方法都進(jìn)行拖拽的邏輯處理,那么我們還需要加上下面這段代碼:
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
if (event != null) updateView(event)
// 是拖拽事件就進(jìn)行攔截弹渔,反之不攔截
// ps:攔截后將不再回調(diào)該方法胳施,所以后續(xù)事件需要在onTouchEvent中回調(diào)
return config.isDrag || super.onInterceptTouchEvent(event)
}
至此,我們解決了控件的拖拽問題肢专,和子View的點(diǎn)擊問題舞肆。
拖拽控件不僅作為Activity浮窗的殼使用焦辅,也可以作為單獨(dú)的控件使用,直接在xml布局文件里包裹其他控件椿胯,就可以實(shí)現(xiàn)相應(yīng)的拖拽效果筷登。
系統(tǒng)浮窗的拖拽實(shí)現(xiàn)有些許的不同,主要是修改坐標(biāo)的方式不同哩盲,核心思想也是一樣的前方。這里就不進(jìn)行展示了,有需要的話廉油,可以看一下相關(guān)代碼镣丑。
3,創(chuàng)建一個(gè)Activity浮窗:
Activity浮窗的創(chuàng)建相對簡單娱两,可以歸納為下面三步:
- 拖拽效果由自定義的拖拽布局實(shí)現(xiàn);
- 將拖拽布局金吗,添加到Activity的根布局十兢;
- 再將浮窗的xml布局,添加到拖拽布局中摇庙,從而實(shí)現(xiàn)拖拽效果旱物。
至于Activity根布局,就是屏幕底層FrameLayout
卫袒,可通過DecorView
進(jìn)行獲认骸:
// 通過DecorView 獲取屏幕底層FrameLayout,即activity的根布局夕凝,作為浮窗的父布局
private var parentFrame: FrameLayout = activity.window.decorView.findViewById(android.R.id.content)
下面就是創(chuàng)建過程:
fun createFloat(config: FloatConfig) {
// 設(shè)置浮窗的拖拽外殼FloatingView
val floatingView = FloatingView(activity).apply {
// 為浮窗打上tag宝穗,如果未設(shè)置tag,使用類名作為tag
tag = getTag(config.floatTag)
// 默認(rèn)wrap_content码秉,會(huì)導(dǎo)致子view的match_parent無效逮矛,所以手動(dòng)設(shè)置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 {
// 如若未設(shè)置固定坐標(biāo),設(shè)置浮窗Gravity
if (config.locationPair == Pair(0, 0)) gravity = config.gravity
}
// 同步配置
setFloatConfig(config)
}
// 將FloatingView添加到根布局中
parentFrame.addView(floatingView)
// 設(shè)置Callbacks
config.callbacks?.createdResult(true, null, floatingView)
config.floatCallbacks?.builder?.createdResult?.invoke(true, null, floatingView)
}
效果就是我們創(chuàng)建的View浮在當(dāng)前Activity上了转砖,而且可拖拽须鼎;結(jié)束當(dāng)前Activity,浮窗也就不存在了府蔗。
4晋控,創(chuàng)建一個(gè)系統(tǒng)浮窗:
這里我們主要看一下,如何把一個(gè)Window
添加到WindowManager
里面的姓赤。
由于創(chuàng)建一個(gè)Window
有很多屬性需要設(shè)置赡译,所以我們先來看一下相關(guān)參數(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
// 設(shè)置浮窗以外的觸摸事件可以傳遞給后面的窗口捶朵、不自動(dòng)獲取焦點(diǎn)蜘矢、可以延伸到屏幕外(設(shè)置動(dòng)畫時(shí)能用到,動(dòng)畫結(jié)束需要去除該屬性综看,不然旋轉(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
// 如若設(shè)置了固定坐標(biāo)品腹,直接定位
if (config.locationPair != Pair(0, 0)) {
x = config.locationPair.first
y = config.locationPair.second
}
}
}
創(chuàng)建思路和Activity浮窗是一致的,只不過這次不是添加到Activity的根布局红碑,而是直接添加到WindowManager
:
private fun createAppFloat() {
// 創(chuàng)建一個(gè)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事件,實(shí)現(xiàn)拖拽效果
frameLayout?.touchListener = object : OnFloatTouchListener {
override fun onTouch(event: MotionEvent) =
touchUtils.updateFloat(frameLayout!!, event, windowManager, params)
}
...
// 設(shè)置入場動(dòng)畫析珊、設(shè)置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)浮窗設(shè)置不同的tag", null)
logger.w("請為系統(tǒng)浮窗設(shè)置不同的tag")
}
/**
* 設(shè)置浮窗的顯隱,用戶主動(dòng)調(diào)用隱藏時(shí)奕剃,needShow需要為false
*/
fun visible(isShow: Boolean, tag: String? = null, needShow: Boolean = true) =
floatMap[getTag(tag)]?.setVisible(if (isShow) View.VISIBLE else View.GONE, needShow)
/**
* 關(guān)閉浮窗衷旅,執(zhí)行浮窗的退出動(dòng)畫
*/
fun dismiss(tag: String? = null) = floatMap[getTag(tag)]?.exitAnim()
/**
* 移除當(dāng)條浮窗信息,在退出完成后調(diào)用
*/
fun remove(floatTag: String?) = floatMap.remove(floatTag)
/**
* 獲取浮窗tag纵朋,為空則使用默認(rèn)值
*/
fun getTag(tag: String?) = tag ?: DEFAULT_TAG
/**
* 獲取具體的系統(tǒng)浮窗管理類
*/
fun getAppFloatManager(tag: String?) = floatMap[getTag(tag)]
/**
* 檢測浮窗的tag是否有效柿顶,不同的浮窗必須設(shè)置不同的tag
*/
private fun checkTag(config: FloatConfig): Boolean {
// 如果未設(shè)置tag,設(shè)置默認(rèn)tag
config.floatTag = getTag(config.floatTag)
return !floatMap.containsKey(config.floatTag!!)
}
}
系統(tǒng)的浮窗的所有管理皆通過此類操软,全部代碼也只有這么多嘁锯,畢竟它只是起到了中轉(zhuǎn)和統(tǒng)一管理的作用;具體的系統(tǒng)浮窗功能聂薪,還是交由AppFloatManager
來實(shí)現(xiàn)的家乘。
6,系統(tǒng)浮窗創(chuàng)建前的權(quán)限管理:
即使是系統(tǒng)浮窗藏澳,安卓6.0之前也是不需要權(quán)限申請的烤低,但這只是存在理想的情況下。由于安卓的碎片化嚴(yán)重笆载,尤其神一樣的國產(chǎn)手機(jī)面前扑馁,適配坑,權(quán)限適配神坑凉驻。
個(gè)人能力有限腻要,遇到這種情況只好選擇站著前人的肩膀上,Android 懸浮窗權(quán)限各機(jī)型各系統(tǒng)適配大全涝登,這篇文章的解決方案還是比較全面的雄家。所以本文的權(quán)限適配使用的此方案,但是該方案只具有適配性胀滚,不具有自主性趟济。
為了提高自主性乱投,我們先進(jìn)行權(quán)限檢測;如果發(fā)現(xiàn)沒有授權(quán)顷编,我們通過Fragment進(jìn)行浮窗權(quán)限的申請戚炫。這樣授權(quán)結(jié)果就不需要寫在我們自己的Activity,直接在Fragment內(nèi)部進(jìn)行媳纬,并且通過接口授權(quán)結(jié)果告訴外部双肤。
其實(shí)所謂的外部,也就是我們的Builder構(gòu)建類钮惠。在我們的構(gòu)建類拿到授權(quán)結(jié)果以后茅糜,根據(jù)授權(quán)情況選擇繼續(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)
// 權(quán)限申請
PermissionUtils.requestPermission(this)
logger.i("PermissionFragment:requestPermission")
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionUtils.requestCode) {
// 需要延遲執(zhí)行蔑赘,不然即使授權(quán),仍有部分機(jī)型獲取不到權(quán)限
Handler(Looper.getMainLooper()).postDelayed({
val check = PermissionUtils.checkPermission(activity)
logger.i("PermissionFragment onActivityResult: $check")
// 回調(diào)權(quán)限結(jié)果
onPermissionResult?.permissionResult(check)
// 將Fragment移除
fragmentManager.beginTransaction().remove(this).commitAllowingStateLoss()
}, 500)
}
}
}
由于在構(gòu)建類調(diào)用的權(quán)限申請预明,使用在此處需要實(shí)現(xiàn)OnPermissionResult
接口:
// 懸浮窗權(quán)限的申請結(jié)果
override fun permissionResult(isOpen: Boolean) {
if (isOpen) createAppFloat()
else config.callbacks?.createdResult(false, "系統(tǒng)浮窗權(quán)限不足缩赛,開啟失敗", null)
}
7,設(shè)置出入動(dòng)畫:
說出入動(dòng)畫前贮庞,我們先回顧下策略模式:定義一系列的算法,把每一個(gè)算法封裝起來究西,并且使它們可相互替換窗慎。策略模式使得算法可獨(dú)立于使用它的客戶而獨(dú)立變化。
- 定義了一族算法(業(yè)務(wù)規(guī)則)卤材;
- 封裝了每個(gè)算法遮斥;
- 這族的算法可互換代替(interchangeable)。
上述三點(diǎn)摘抄自維基百科扇丛,簡單說就是可以通過不同的實(shí)現(xiàn)過程术吗,給出想要的實(shí)現(xiàn)結(jié)果。
如:某接口或某抽象類帆精,包含排序算法较屿,至于我們怎么排序:使用冒牌排序、快速排序卓练,還是其他的排序都是可以的隘蝎。
接下來我們一起看輪子中的策略實(shí)例,由于Activity浮窗和系統(tǒng)浮窗的創(chuàng)建方式不同襟企,動(dòng)畫實(shí)現(xiàn)也有些許不同嘱么。但流程相同,這里以Activity浮窗動(dòng)畫作為展示顽悼。
- 首先我們定義一個(gè)抽象策略基類曼振,動(dòng)畫接口:
interface OnFloatAnimator {
// 入場動(dòng)畫
fun enterAnim(view: View, parentView: ViewGroup, sidePattern: SidePattern): Animator? = null
// 退出動(dòng)畫
fun exitAnim(view: View, parentView: ViewGroup, sidePattern: SidePattern): Animator? = null
}
- 創(chuàng)建具體策略類几迄,也就是默認(rèn)動(dòng)畫實(shí)現(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()
// 實(shí)現(xiàn)接口中的入場動(dòng)畫,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)
}
... // 退出動(dòng)畫
/**
* 設(shè)置動(dòng)畫類型映胁,計(jì)算具體數(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
/**
* 計(jì)算一些數(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)境類屿愚,也就是動(dòng)畫管理類:
internal class AnimatorManager(
private val onFloatAnimator: OnFloatAnimator?,
private val view: View,
private val parentView: ViewGroup,
private val sidePattern: SidePattern
) {
// 通過接口實(shí)現(xiàn)具體動(dòng)畫,所以只需要更改接口的具體實(shí)現(xiàn)
fun enterAnim(): Animator? = onFloatAnimator?.enterAnim(view, parentView, sidePattern)
fun exitAnim(): Animator? = onFloatAnimator?.exitAnim(view, parentView, sidePattern)
}
準(zhǔn)備工作都準(zhǔn)備妥當(dāng)了务荆,那我們在哪里調(diào)用動(dòng)畫吶妆距?
入場動(dòng)畫:肯定是在浮窗創(chuàng)建完成的時(shí)候調(diào)用,所以我們在拖拽控件的onLayout
方法里調(diào)用入場動(dòng)畫函匕。不過有個(gè)細(xì)節(jié)要注意娱据,只有在第一次執(zhí)行onLayout
方法時(shí)才調(diào)用入場動(dòng)畫,因?yàn)殡[藏再顯示盅惜,也是會(huì)調(diào)用onLayout
方法的中剩。
退出動(dòng)畫:則在我們調(diào)用關(guān)閉浮窗時(shí)調(diào)用。如果退出動(dòng)畫不為空抒寂,先執(zhí)行動(dòng)畫结啼,動(dòng)畫結(jié)束的時(shí)候銷毀浮窗控件;如果退出動(dòng)畫為空屈芜,則直接銷毀浮窗郊愧。
- 動(dòng)畫的使用,以退出動(dòng)畫為例:
internal fun exitAnim() {
// 正在執(zhí)行動(dòng)畫井佑,防止重復(fù)調(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)部做了動(dòng)畫的監(jiān)聽和執(zhí)行,config.floatAnimator
就是我們外部傳入的動(dòng)畫實(shí)現(xiàn)類躬翁。
動(dòng)畫類型也沒有做過多限制焦蘑,使用的是動(dòng)畫的超類Animator
,所以視圖動(dòng)畫和屬性動(dòng)畫都是可以的盒发;不需要?jiǎng)赢嬛苯釉趯?shí)現(xiàn)類里返回null即可例嘱。
8,頁面過濾和僅前臺(tái)顯示:
前面我們說屬性管理的時(shí)候宁舰,在FloatConfig
數(shù)據(jù)類里蝶防,有下面這個(gè)屬性:
// 不需要顯示系統(tǒng)浮窗的頁面集合,參數(shù)為類名
val filterSet: MutableSet<String> = mutableSetOf()
這個(gè)頁面過濾集合明吩,可以在創(chuàng)建浮窗的時(shí)候就設(shè)置间学,也可以在需要的時(shí)候進(jìn)行設(shè)置。集合數(shù)據(jù)好管理,主要是過濾功能是如何實(shí)現(xiàn)的低葫。
在Application類中详羡,ActivityLifecycleCallbacks
可以實(shí)現(xiàn)各個(gè)Activity的生命周期監(jiān)控,我們只要在特定的Activity顯示時(shí)控制浮窗隱藏嘿悬,在Activity不顯示時(shí)再重新讓浮窗顯示实柠。
同理,如果讓浮窗實(shí)現(xiàn)僅前臺(tái)顯示善涨,也可以使用此方式窒盐,當(dāng)所有的Activity都不顯示的時(shí)候,浮窗隱藏钢拧,反正浮窗重新顯示蟹漓。
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 {
// 如果手動(dòng)隱藏浮窗,不再考慮過濾信息
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
}
}
// 當(dāng)過濾信息沒有匹配上時(shí)源内,需要發(fā)送廣播葡粒,反之修改needShow為默認(rèn)值
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
// 當(dāng)app處于后臺(tái)時(shí),檢測是否有僅前臺(tái)顯示的系統(tǒng)浮窗
FloatManager.floatMap.forEach { (tag, manager) ->
run {
// 如果手動(dòng)隱藏浮窗膜钓,不再考慮過濾信息
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,即在項(xiàng)目的Application中需要進(jìn)行浮窗的初始化颂斜;如果沒使用到過濾和僅前臺(tái)顯示夫壁,則不需要。
實(shí)施階段也就說這么多吧沃疮,其他一些點(diǎn)和一些注意細(xì)節(jié)盒让,都在代碼中,感興趣的可以去看下忿磅。
使用:上手體驗(yàn)
說了這么多糯彬,到底好不好用吶凭语?我們寫個(gè)最簡單的浮窗:
EasyFloat.with(this).setLayout(R.layout.float_test).show()
對葱她,沒有看錯(cuò),一行代碼就可以創(chuàng)建一個(gè)拖拽浮窗似扔,默認(rèn)只在當(dāng)頁顯示吨些。
作為結(jié)束,我們從上圖中挑一個(gè)來實(shí)現(xiàn)炒辉。由于浮窗只支持拖拽豪墅,不支持縮放,那我們就選那個(gè)支持縮放的系統(tǒng)浮窗吧:
上圖中一共包含了這幾個(gè)屬性:設(shè)置僅前臺(tái)顯示黔寇、過濾SecondActivity偶器、固定坐標(biāo)、取消出入動(dòng)畫、點(diǎn)擊關(guān)閉屏轰、拖拽縮放颊郎。
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()
}
需要指出的是,這里的拖拽縮放不包含在輪子中霎苗,在示例代碼里姆吭。我們一塊看下是怎么實(shí)現(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)
// 屏蔽掉浮窗的事件攔截唁盏,僅由自身消費(fèi)
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
}
}
邏輯很簡單内狸,只是記錄手指相對于按下時(shí)的滑動(dòng)距離,外部根據(jù)這個(gè)距離差值厘擂,從新設(shè)置控件大小昆淡。關(guān)鍵一點(diǎn)要屏蔽掉浮窗的事件攔截,不然接收不到觸摸事件驴党。
文章到這里就已經(jīng)全部結(jié)束了瘪撇,非常感謝大家的閱讀。
輪子已上傳到GitHub港庄,希望對大家有所幫助倔既,如果能收獲個(gè)Star,那也最開心不過了鹏氧。
項(xiàng)目地址:https://github.com/princekin-f/EasyFloat
特別感謝:Android 懸浮窗權(quán)限各機(jī)型各系統(tǒng)適配大全
說在后面:
系統(tǒng)浮窗的管理原先使用的是Service渤涌,坑神多!借鑒別人的同時(shí)把还,也應(yīng)保持質(zhì)疑和思考……