EasyFloat:浮窗從未如此簡(jiǎn)單

應(yīng)用浮窗由于良好的便捷性和拓展性,在某些場(chǎng)景下有著不錯(cuò)的交互體驗(yàn)。
恰巧項(xiàng)目需求有用到,可是逛了一圈GitHub,并沒有找到滿意的浮窗控件。
索性造個(gè)好用的輪子,方便你我他,遂成此文。
GitHub地址:EasyFloat

需求:我們想要什么

  • 要能浮在某個(gè)單獨(dú)的頁面上,或者多個(gè)頁面上;
  • 要支持拖拽,這樣才夠靈活;
  • 可能需要吸附邊緣,也可能不需要吸附;
  • 要支持浮窗內(nèi)部的點(diǎn)擊、拖拽;
  • 要靈活的控制浮窗的顯示、隱藏、銷毀等;
  • 要能夠自行設(shè)定出入動(dòng)畫,這樣才夠炫酷、個(gè)性;
  • 要能夠過濾不需要顯示的頁面;
  • 要能夠指定位置、設(shè)置對(duì)齊方式和偏移量;
  • 權(quán)限管理要簡(jiǎn)單,能不需要最好;
  • 要能有各個(gè)狀態(tài)的監(jiān)測(cè)、方便拓展;
  • 還得使用方便、兼容性要強(qiáng),要能在系統(tǒng)浮窗中使用輸入框;
  • 反正想要的很多...

這么多需求,應(yīng)該能滿足非極端使用場(chǎng)景了??墒沁@么多需求,我們需要如何一步步實(shí)現(xiàn)吶?

分析:假裝頭腦風(fēng)暴

1,如何浮在其他視圖之上:

我們知道想要把View浮在其他視圖之上,有兩種實(shí)現(xiàn)方式:

  • 將View添加到Activity的根布局,由于根布局是個(gè)FrameLayout,所以后添加的上層顯示;
  • 創(chuàng)建Window窗口,直接將View添加到WindowManager中,這樣可以實(shí)現(xiàn)在所有的頁面顯示。

添加到Activity根布局相對(duì)比較簡(jiǎn)單,也不需要額外的權(quán)限??墒亲畲蟮膯栴}是跟隨Activity生命周期,只能在當(dāng)前Activity顯示。

Window窗口則能很好的解決全局顯示的問題,可是在Android 6.0之后(特殊機(jī)型除外),使用TYPE_APPLICATION_OVERLAY屬性,需要進(jìn)行懸浮窗權(quán)限的申請(qǐng),必須手動(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)限申請(qǐng),權(quán)限如何處理:

既然是權(quán)限相關(guān)的操作,肯定包括下面三個(gè)部分:

  • 懸浮窗權(quán)限的檢測(cè);
  • 有權(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)限申請(qǐng),需要在onActivityResult處理授權(quán)結(jié)果,所以只能在Activity或者Fragment中進(jìn)行。
作為一個(gè)合格的輪子,我們肯定不能選擇在Activity中操作;所以我們選擇在輪子內(nèi)部維護(hù)一個(gè)不可見的Fragment,進(jìn)行權(quán)限的申請(qǐng)和授權(quán)結(jié)果的后續(xù)操作,在不需要的時(shí)候移除Fragment。

4,系統(tǒng)浮窗生命周期很長(zhǎng),如何創(chuàng)建、如何管理:

由于系統(tǒng)浮窗是作為全局使用的,生命周期很長(zhǎng)。如果直接在Activity創(chuàng)建,當(dāng)遇到Activity被銷毀時(shí),這時(shí)的浮窗將是不可控的,滿足不了我們的需求啊。

怎么辦吶?首先我們想到是,通過一個(gè)管理者管理一個(gè)特定浮窗的所有事務(wù),這樣我們只要擁有了這個(gè)管理者,就完成了對(duì)這個(gè)浮窗的掌控。可是這個(gè)管理者,應(yīng)該存放在哪里?尤其是要生命周期足夠長(zhǎ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é)果。
我們把入場(chǎng)動(dòng)畫、退場(chǎng)動(dòng)畫的方法,定義在策略基類中;稍加操作,應(yīng)有盡有...

分析過程就闡述這么多吧,這里進(jìn)行了粗略的邏輯整理,我們一起看下:

EasyFloat流程圖

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

權(quán)限申請(qǐng) 系統(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è)簡(jiǎn)易的浮窗。

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
            // 檢測(cè)是否到達(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方法可以對(duì)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)建相對(duì)簡(jiǎn)單,可以歸納為下面三步:

  • 拖拽效果由自定義的拖拽布局實(shí)現(xiàn);
  • 將拖拽布局,添加到Activity的根布局;
  • 再將浮窗的xml布局,添加到拖拽布局中,從而實(shí)現(xiàn)拖拽效果。

至于Activity根布局,就是屏幕底層FrameLayout,可通過DecorView進(jìn)行獲?。?/p>

// 通過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è)置入場(chǎng)動(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, "請(qǐng)為系統(tǒng)浮窗設(shè)置不同的tag", null)
        logger.w("請(qǐng)為系統(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)]

    /**
     * 檢測(cè)浮窗的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)限申請(qǐng)的,但這只是存在理想的情況下。由于安卓的碎片化嚴(yán)重,尤其神一樣的國(guó)產(chǎn)手機(jī)面前,適配坑,權(quán)限適配神坑。

個(gè)人能力有限,遇到這種情況只好選擇站著前人的肩膀上,Android 懸浮窗權(quán)限各機(jī)型各系統(tǒng)適配大全,這篇文章的解決方案還是比較全面的。所以本文的權(quán)限適配使用的此方案,但是該方案只具有適配性,不具有自主性。

為了提高自主性,我們先進(jìn)行權(quán)限檢測(cè);如果發(fā)現(xiàn)沒有授權(quán),我們通過Fragment進(jìn)行浮窗權(quán)限的申請(qǐng)。這樣授權(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)限申請(qǐng)
        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)限申請(qǐng),使用在此處需要實(shí)現(xiàn)OnPermissionResult接口:

// 懸浮窗權(quán)限的申請(qǐng)結(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)摘抄自維基百科,簡(jiǎn)單說就是可以通過不同的實(shí)現(xiàn)過程,給出想要的實(shí)現(xiàn)結(jié)果。
如:某接口或某抽象類,包含排序算法,至于我們?cè)趺磁判颍菏褂妹芭婆判?、快速排序,還是其他的排序都是可以的。

策略模式UML圖.jpg

接下來我們一起看輪子中的策略實(shí)例,由于Activity浮窗和系統(tǒng)浮窗的創(chuàng)建方式不同,動(dòng)畫實(shí)現(xiàn)也有些許不同。但流程相同,這里以Activity浮窗動(dòng)畫作為展示。

  • 首先我們定義一個(gè)抽象策略基類,動(dòng)畫接口:
interface OnFloatAnimator {
    // 入場(chǎng)動(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)接口中的入場(chǎng)動(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)了,那我們?cè)谀睦镎{(diào)用動(dòng)畫吶?

入場(chǎng)動(dòng)畫:肯定是在浮窗創(chuàng)建完成的時(shí)候調(diào)用,所以我們?cè)谕献Э丶?code>onLayout方法里調(diào)用入場(chǎng)動(dòng)畫。不過有個(gè)細(xì)節(jié)要注意,只有在第一次執(zhí)行onLayout方法時(shí)才調(diào)用入場(chǎng)動(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í),檢測(cè)是否有僅前臺(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è)最簡(jiǎn)單的浮窗:

EasyFloat.with(this).setLayout(R.layout.float_test).show()

對(duì),沒有看錯(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
    }
}

邏輯很簡(jiǎn)單,只是記錄手指相對(duì)于按下時(shí)的滑動(dòng)距離,外部根據(jù)這個(gè)距離差值,從新設(shè)置控件大小。關(guān)鍵一點(diǎn)要屏蔽掉浮窗的事件攔截,不然接收不到觸摸事件。


文章到這里就已經(jīng)全部結(jié)束了,非常感謝大家的閱讀。
輪子已上傳到GitHub,希望對(duì)大家有所幫助,如果能收獲個(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)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

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

友情鏈接更多精彩內(nèi)容