EasyFloat:浮窗從未如此簡單

應(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)行了粗略的邏輯整理粉渠,我們一起看下:

EasyFloat流程圖

說一千道一萬,還是圖片來的更直觀圾另,那有沒有更直觀的吶霸株?
還真有,我們一起看一下效果圖吧:

權(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é)果。
如:某接口或某抽象類帆精,包含排序算法较屿,至于我們怎么排序:使用冒牌排序、快速排序卓练,還是其他的排序都是可以的隘蝎。

策略模式UML圖.jpg

接下來我們一起看輪子中的策略實(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ì)疑和思考……

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末实蓬,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子吊履,更是在濱河造成了極大的恐慌安皱,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,277評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件艇炎,死亡現(xiàn)場離奇詭異酌伊,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)缀踪,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,689評論 3 393
  • 文/潘曉璐 我一進(jìn)店門居砖,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人驴娃,你說我怎么就攤上這事奏候。” “怎么了唇敞?”我有些...
    開封第一講書人閱讀 163,624評論 0 353
  • 文/不壞的土叔 我叫張陵蔗草,是天一觀的道長咒彤。 經(jīng)常有香客問我,道長咒精,這世上最難降的妖魔是什么蔼紧? 我笑而不...
    開封第一講書人閱讀 58,356評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮狠轻,結(jié)果婚禮上奸例,老公的妹妹穿的比我還像新娘。我一直安慰自己向楼,他們只是感情好查吊,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,402評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著湖蜕,像睡著了一般逻卖。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上昭抒,一...
    開封第一講書人閱讀 51,292評論 1 301
  • 那天评也,我揣著相機(jī)與錄音,去河邊找鬼灭返。 笑死盗迟,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的熙含。 我是一名探鬼主播罚缕,決...
    沈念sama閱讀 40,135評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼怎静!你這毒婦竟也來了邮弹?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,992評論 0 275
  • 序言:老撾萬榮一對情侶失蹤蚓聘,失蹤者是張志新(化名)和其女友劉穎腌乡,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體夜牡,經(jīng)...
    沈念sama閱讀 45,429評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡与纽,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,636評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了氯材。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片渣锦。...
    茶點(diǎn)故事閱讀 39,785評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡硝岗,死狀恐怖氢哮,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情型檀,我是刑警寧澤冗尤,帶...
    沈念sama閱讀 35,492評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響裂七,放射性物質(zhì)發(fā)生泄漏皆看。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,092評論 3 328
  • 文/蒙蒙 一背零、第九天 我趴在偏房一處隱蔽的房頂上張望腰吟。 院中可真熱鬧,春花似錦徙瓶、人聲如沸毛雇。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,723評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽灵疮。三九已至,卻和暖如春壳繁,著一層夾襖步出監(jiān)牢的瞬間震捣,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,858評論 1 269
  • 我被黑心中介騙來泰國打工闹炉, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留蒿赢,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,891評論 2 370
  • 正文 我出身青樓渣触,卻偏偏與公主長得像诉植,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子昵观,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,713評論 2 354

推薦閱讀更多精彩內(nèi)容

  • ¥開啟¥ 【iAPP實(shí)現(xiàn)進(jìn)入界面執(zhí)行逐一顯】 〖2017-08-25 15:22:14〗 《//首先開一個(gè)線程晾腔,因...
    小菜c閱讀 6,408評論 0 17
  • 其實(shí)寫東西不僅僅需要靈感,還需要感覺吧啊犬。 剛回到家不久灼擂,把今天買的東西全部放在椅子上,整個(gè)人就累到不行了觉至。 想起之...
    歡歡不是一條狗閱讀 286評論 0 0
  • 加密算法與SSL及創(chuàng)建私有CA 標(biāo)簽(空格分隔): Linux 運(yùn)維 加密解密 算法 三個(gè)維度驗(yàn)證數(shù)據(jù) 機(jī)密性: ...
    uangianlap閱讀 1,040評論 0 0
  • 如果人生 是一場修行 那么熱愛 就是你修行的 拐杖
    咖啡貓的故事閱讀 175評論 0 0
  • 今日復(fù)盤: 1 今日待完成看書半小時(shí)剔应,回家路上完成。 2 今日試驗(yàn)了 蝸牛睡眠语御,結(jié)果發(fā)現(xiàn)并不靠譜峻贮,并不會(huì)真的有深度...
    咩咩媽閱讀 157評論 0 0