EasyFloat:浮窗从未如此简单

应用浮窗由于良好的便捷性和拓展性,在某些场景下有着不错的交互体验。
恰巧项目需求有用到,可是逛了一圈GitHub,并没有找到满意的浮窗控件。
索性造个好用的轮子,方便你我他,遂成此文。
GitHub地址:EasyFloat

需求:我们想要什么

  • 要能浮在某个单独的页面上,或者多个页面上;
  • 要支持拖拽,这样才够灵活;
  • 可能需要吸附边缘,也可能不需要吸附;
  • 要支持浮窗内部的点击、拖拽;
  • 要灵活的控制浮窗的显示、隐藏、销毁等;
  • 要能够自行设定出入动画,这样才够炫酷、个性;
  • 要能够过滤不需要显示的页面;
  • 要能够指定位置、设置对齐方式和偏移量;
  • 权限管理要简单,能不需要最好;
  • 要能有各个状态的监测、方便拓展;
  • 还得使用方便、兼容性要强,要能在系统浮窗中使用输入框;
  • 反正想要的很多...

这么多需求,应该能满足非极端使用场景了。可是这么多需求,我们需要如何一步步实现呐?

分析:假装头脑风暴

1,如何浮在其他视图之上:

我们知道想要把View浮在其他视图之上,有两种实现方式:

  • 将View添加到Activity的根布局,由于根布局是个FrameLayout,所以后添加的上层显示;
  • 创建Window窗口,直接将View添加到WindowManager中,这样可以实现在所有的页面显示。

添加到Activity根布局相对比较简单,也不需要额外的权限。可是最大的问题是跟随Activity生命周期,只能在当前Activity显示。

Window窗口则能很好的解决全局显示的问题,可是在Android 6.0之后(特殊机型除外),使用TYPE_APPLICATION_OVERLAY属性,需要进行悬浮窗权限的申请,必须手动授权。如果我们只需要在当前页面使用浮窗功能,又会觉得太重,使用不方便。

那我们改如何抉择两者?答案:都用,根据浮窗类型使用不同的创建方式。

2,怎么拖拽、怎么设置View:

既然要实现拖拽,肯定要从Touch事件下手,是单纯的onTouchEvent重写,还是要结合onInterceptTouchEvent作操作,我们后面再细说。但无论我们是以哪种方式创建的浮窗,都可以通过Touch事件实现拖拽效果,只是一些实现细节的不同。

既然说两种浮窗的拖拽过程,有些许不同,那我们最好不要把自定义的拖拽View放在xml的根节点。因为那样我们写布局文件的时候,还需要进行区分;所以我们把拖拽View作为壳,放在浮窗控件的内部,我们只需设置要展示的xml布局,然后将xml布局添加到拖拽壳里面,各司其职。

3,系统浮窗需要权限申请,权限如何处理:

既然是权限相关的操作,肯定包括下面三个部分:

  • 悬浮窗权限的检测;
  • 有权限则直接创建,没有权限则跳转到权限授权页;
  • 根据授权结果,继续创建浮窗或者回调创建失败。

这些操作可以由开发人员一步步完成,但作为喜欢偷懒的我们,肯定希望轮子能够自主完成这一切。但是我们应该怎么做呐?

由于权限申请,需要在onActivityResult处理授权结果,所以只能在Activity或者Fragment中进行。
作为一个合格的轮子,我们肯定不能选择在Activity中操作;所以我们选择在轮子内部维护一个不可见的Fragment,进行权限的申请和授权结果的后续操作,在不需要的时候移除Fragment。

4,系统浮窗生命周期很长,如何创建、如何管理:

由于系统浮窗是作为全局使用的,生命周期很长。如果直接在Activity创建,当遇到Activity被销毁时,这时的浮窗将是不可控的,满足不了我们的需求啊。

怎么办呐?首先我们想到是,通过一个管理者管理一个特定浮窗的所有事务,这样我们只要拥有了这个管理者,就完成了对这个浮窗的掌控。可是这个管理者,应该存放在哪里?尤其是要生命周期足够长。
答案就是,通过单例静态类,管理所有的系统浮窗管理者。通过静态容器存放具体的浮窗管理者,每个浮窗的Tag作为索引值,管理起来相当方便,数据也相当稳健。

5,如果只要前台显示、或者有页面不需要显示怎么办:

想要只在前台显示,我们首先要做的就是获取前后台的状态,这个应该怎么做呐?

我们可以通过ActivityLifecycleCallbacks感知各个Activity的生命周期,通过计算打开和关闭Activity的数目,就可以知道当前APP处于前台还是后台;然后根据前后台发广播控制浮窗显示或者隐藏。

同理,有需要过滤的Activity,我们只需要监听它的生命周期变化,然后去控制显示和隐藏就好了。

6,我们需要出入动画,还不想每个都一样:

学过策略模式的都应该知道,只要实现相应的接口或者复写抽象方法,就可以去做你想要的结果。
我们把入场动画、退场动画的方法,定义在策略基类中;稍加操作,应有尽有...

分析过程就阐述这么多吧,这里进行了粗略的逻辑整理,我们一起看下:

EasyFloat流程图

说一千道一万,还是图片来的更直观,那有没有更直观的呐?
还真有,我们一起看一下效果图吧:

权限申请 系统浮窗
前台和过滤 状态回调
View修改 拓展使用

效果大致就是这个样子,如果感兴趣,我们一起看看是怎么实现的...

实施:那我们动手了

1,属性管理:

工欲善其事,必先利其器。
既然浮窗属性比较多,为了方便管理,我们建个属性管理类,将各属性放在一起,统一管理:

data class FloatConfig(
    // 浮窗的xml布局文件
    var layoutId: Int? = null,
    // 当前浮窗的tag
    var floatTag: String? = null,
    // 是否可拖拽
    var dragEnable: Boolean = true,
    // 是否正在被拖拽
    var isDrag: Boolean = false,
    // 是否正在执行动画
    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,
    // 浮窗的摆放方式,使用系统的Gravity属性
    var gravity: Int = 0,
    // 坐标的偏移量
    var offsetPair: Pair<Int,Int> = Pair(0,0),
    // 固定的初始坐标,左上角坐标
    var locationPair: Pair<Int, Int> = Pair(0, 0),
    // ps:优先使用固定坐标,若固定坐标不为原点坐标,gravity属性和offset属性无效
    // Callbacks
    var invokeView: OnInvokeView? = null,
    var callbacks: OnFloatCallbacks? = null,
    // 出入动画
    var floatAnimator: OnFloatAnimator? = DefaultAnimator(),
    var appFloatAnimator: OnAppFloatAnimator? = AppFloatDefaultAnimator(),
    // 不需要显示系统浮窗的页面集合,参数为类名
    val filterSet: MutableSet<String> = mutableSetOf(),
    // 是否需要显示,当过滤信息匹配上时,该值为false
    internal var needShow: Boolean = true
)

属性都是一步步添加的,这里我们直接展示了最终的属性列表。
为了使用方便,我们还为每个属性设置了默认值,这样即使不配什么参数,也可以创建一个简易的浮窗。

2,写一个支持拖拽的普通控件:

前面我们有说过,拖拽功能在于重写Touch事件。所以我们就写一个自己的控件,继承自ViewGroup,这里我们使用的是FrameLayout,然后重写onTouchEvent方法:

override fun onTouchEvent(event: MotionEvent?): Boolean {
    // updateView(event)是拖拽功能的具体实现
    if (event != null) updateView(event)
    // 如果是拖拽,这消费此事件,否则返回默认情况,防止影响子View事件的消费
    return config.isDrag || super.onTouchEvent(event)
}

拖拽功能的实现思路就是:记录ACTION_DOWN的坐标信息,在发生ACTION_MOVE的时候,计算两者的差值,为View设置新的坐标;并且记录更新后的坐标,为下次ACTION_MOVE提供新的基准。

private fun updateView(event: MotionEvent) {
    // 关闭拖拽/执行动画阶段,不可拖动
    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
            // 父布局不要拦截子布局的监听
            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
        }

        // 如果是拖动状态下即非点击按压事件
        MotionEvent.ACTION_UP ->  isPressed = !config.isDrag

        else -> return
    }
}

由于项目支持多种吸附方式和回调,真实情况比示例代码复杂许多,但核心代码如此。

这下拖拽效果是有的,可是在使用中发现了新的问题:如果子View有点击事件,会导致该控件的拖拽失效。

这是由于安卓的Touch事件传递机制导致的,子View优先享用Touch事件;默认情况下,只有在子View不消费事件的情况下,父控件才能够接受到事件。

那我们有什么方法改变这一现状呐?好在父控件存在拦截机制,使用onInterceptTouchEvent方法可以对Touch事件进行拦截,优先使用Touch事件。

当返回值为true的时候,代表我们将事件进行了拦截,子View将不会在收到Touch事件,并且会调用当前控件的onTouchEvent方法。

所以我们需要在onTouchEvent方法和onInterceptTouchEvent方法都进行拖拽的逻辑处理,那么我们还需要加上下面这段代码:

override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
    if (event != null) updateView(event)
    // 是拖拽事件就进行拦截,反之不拦截
    // ps:拦截后将不再回调该方法,所以后续事件需要在onTouchEvent中回调
    return config.isDrag || super.onInterceptTouchEvent(event)
}

至此,我们解决了控件的拖拽问题,和子View的点击问题。

拖拽控件不仅作为Activity浮窗的壳使用,也可以作为单独的控件使用,直接在xml布局文件里包裹其他控件,就可以实现相应的拖拽效果。

系统浮窗的拖拽实现有些许的不同,主要是修改坐标的方式不同,核心思想也是一样的。这里就不进行展示了,有需要的话,可以看一下相关代码。

3,创建一个Activity浮窗:

Activity浮窗的创建相对简单,可以归纳为下面三步:

  • 拖拽效果由自定义的拖拽布局实现;
  • 将拖拽布局,添加到Activity的根布局;
  • 再将浮窗的xml布局,添加到拖拽布局中,从而实现拖拽效果。

至于Activity根布局,就是屏幕底层FrameLayout,可通过DecorView进行获取:

// 通过DecorView 获取屏幕底层FrameLayout,即activity的根布局,作为浮窗的父布局
private var parentFrame: FrameLayout = activity.window.decorView.findViewById(android.R.id.content)

下面就是创建过程:

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)
}

效果就是我们创建的View浮在当前Activity上了,而且可拖拽;结束当前Activity,浮窗也就不存在了。

4,创建一个系统浮窗:

这里我们主要看一下,如何把一个Window添加到WindowManager里面的。
由于创建一个Window有很多属性需要设置,所以我们先来看一下相关参数的初始化:

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
        // 设置浮窗以外的触摸事件可以传递给后面的窗口、不自动获取焦点、可以延伸到屏幕外(设置动画时能用到,动画结束需要去除该属性,不然旋转屏幕可能置于屏幕外部)
        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
        }
    }
}

创建思路和Activity浮窗是一致的,只不过这次不是添加到Activity的根布局,而是直接添加到WindowManager

private fun createAppFloat() {
    // 创建一个frameLayout作为浮窗布局的父容器
    frameLayout = ParentFrameLayout(context, config)
    frameLayout?.tag = config.floatTag
    // 将浮窗布局文件添加到父容器frameLayout中,并返回该浮窗文件
    val floatingView = LayoutInflater.from(context.applicationContext)
        .inflate(config.layoutId!!, frameLayout, true)
    // 将frameLayout添加到系统windowManager中
    windowManager.addView(frameLayout, params)

    // 通过重写frameLayout的Touch事件,实现拖拽效果
    frameLayout?.touchListener = object : OnFloatTouchListener {
        override fun onTouch(event: MotionEvent) =
            touchUtils.updateFloat(frameLayout!!, event, windowManager, params)
    }

    ...
    // 设置入场动画、设置Callbacks
}

5,通过静态集合管理所有的系统浮窗:

internal object FloatManager {

    private const val DEFAULT_TAG = "default"
    val floatMap = mutableMapOf<String, AppFloatManager>()

    /**
     * 创建系统浮窗,首先检查浮窗是否存在:不存在则创建,存在则回调提示
     */
    fun create(context: Context, config: FloatConfig) = if (checkTag(config)) {
        // 通过floatManager创建浮窗,并将floatManager添加到map中
        floatMap[config.floatTag!!] = AppFloatManager(context.applicationContext, config)
            .apply { createFloat() }
    } else {
        config.callbacks?.createdResult(false, "请为系统浮窗设置不同的tag", null)
        logger.w("请为系统浮窗设置不同的tag")
    }

    /**
     * 设置浮窗的显隐,用户主动调用隐藏时,needShow需要为false
     */
    fun visible(isShow: Boolean, tag: String? = null, needShow: Boolean = true) =
        floatMap[getTag(tag)]?.setVisible(if (isShow) View.VISIBLE else View.GONE, needShow)

    /**
     * 关闭浮窗,执行浮窗的退出动画
     */
    fun dismiss(tag: String? = null) = floatMap[getTag(tag)]?.exitAnim()

    /**
     * 移除当条浮窗信息,在退出完成后调用
     */
    fun remove(floatTag: String?) = floatMap.remove(floatTag)

    /**
     * 获取浮窗tag,为空则使用默认值
     */
    fun getTag(tag: String?) = tag ?: DEFAULT_TAG

    /**
     * 获取具体的系统浮窗管理类
     */
    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!!)
    }
}

系统的浮窗的所有管理皆通过此类,全部代码也只有这么多,毕竟它只是起到了中转和统一管理的作用;具体的系统浮窗功能,还是交由AppFloatManager来实现的。

6,系统浮窗创建前的权限管理:

即使是系统浮窗,安卓6.0之前也是不需要权限申请的,但这只是存在理想的情况下。由于安卓的碎片化严重,尤其神一样的国产手机面前,适配坑,权限适配神坑。

个人能力有限,遇到这种情况只好选择站着前人的肩膀上,Android 悬浮窗权限各机型各系统适配大全,这篇文章的解决方案还是比较全面的。所以本文的权限适配使用的此方案,但是该方案只具有适配性,不具有自主性。

为了提高自主性,我们先进行权限检测;如果发现没有授权,我们通过Fragment进行浮窗权限的申请。这样授权结果就不需要写在我们自己的Activity,直接在Fragment内部进行,并且通过接口授权结果告诉外部。

其实所谓的外部,也就是我们的Builder构建类。在我们的构建类拿到授权结果以后,根据授权情况选择继续创建浮窗,或者回调创建失败。

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) {
            // 需要延迟执行,不然即使授权,仍有部分机型获取不到权限
            Handler(Looper.getMainLooper()).postDelayed({
                val check = PermissionUtils.checkPermission(activity)
                logger.i("PermissionFragment onActivityResult: $check")
                // 回调权限结果
                onPermissionResult?.permissionResult(check)
                // 将Fragment移除
                fragmentManager.beginTransaction().remove(this).commitAllowingStateLoss()
            }, 500)
        }
    }
}

由于在构建类调用的权限申请,使用在此处需要实现OnPermissionResult接口:

// 悬浮窗权限的申请结果
override fun permissionResult(isOpen: Boolean) {
    if (isOpen) createAppFloat()
    else config.callbacks?.createdResult(false, "系统浮窗权限不足,开启失败", null)
}

7,设置出入动画:

说出入动画前,我们先回顾下策略模式:定义一系列的算法,把每一个算法封装起来,并且使它们可相互替换。策略模式使得算法可独立于使用它的客户而独立变化。

  • 定义了一族算法(业务规则);
  • 封装了每个算法;
  • 这族的算法可互换代替(interchangeable)。

上述三点摘抄自维基百科,简单说就是可以通过不同的实现过程,给出想要的实现结果。
如:某接口或某抽象类,包含排序算法,至于我们怎么排序:使用冒牌排序、快速排序,还是其他的排序都是可以的。

策略模式UML图.jpg

接下来我们一起看轮子中的策略实例,由于Activity浮窗和系统浮窗的创建方式不同,动画实现也有些许不同。但流程相同,这里以Activity浮窗动画作为展示。

  • 首先我们定义一个抽象策略基类,动画接口:
interface OnFloatAnimator {
    // 入场动画
    fun enterAnim(view: View, parentView: ViewGroup, sidePattern: SidePattern): Animator? = null
    // 退出动画
    fun exitAnim(view: View, parentView: ViewGroup, sidePattern: SidePattern): Animator? = null
}
  • 创建具体策略类,也就是默认动画实现类:
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()

    // 实现接口中的入场动画,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)
    }
    ...  // 退出动画
    
    /**
     * 设置动画类型,计算具体数值
     */
    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

    /**
     * 计算一些数值,方便使用
     */
    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)
    }
}
  • 创建环境类,也就是动画管理类:
internal class AnimatorManager(
    private val onFloatAnimator: OnFloatAnimator?,
    private val view: View,
    private val parentView: ViewGroup,
    private val sidePattern: SidePattern
) {
    // 通过接口实现具体动画,所以只需要更改接口的具体实现
    fun enterAnim(): Animator? = onFloatAnimator?.enterAnim(view, parentView, sidePattern)
    fun exitAnim(): Animator? = onFloatAnimator?.exitAnim(view, parentView, sidePattern)
}

准备工作都准备妥当了,那我们在哪里调用动画呐?

入场动画:肯定是在浮窗创建完成的时候调用,所以我们在拖拽控件的onLayout方法里调用入场动画。不过有个细节要注意,只有在第一次执行onLayout方法时才调用入场动画,因为隐藏再显示,也是会调用onLayout方法的。

退出动画:则在我们调用关闭浮窗时调用。如果退出动画不为空,先执行动画,动画结束的时候销毁浮窗控件;如果退出动画为空,则直接销毁浮窗。

  • 动画的使用,以退出动画为例:
internal fun exitAnim() {
    // 正在执行动画,防止重复调用
    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()
    }
}

看得出来,我们内部做了动画的监听和执行,config.floatAnimator就是我们外部传入的动画实现类。

动画类型也没有做过多限制,使用的是动画的超类Animator,所以视图动画和属性动画都是可以的;不需要动画直接在实现类里返回null即可。

8,页面过滤和仅前台显示:

前面我们说属性管理的时候,在FloatConfig数据类里,有下面这个属性:

// 不需要显示系统浮窗的页面集合,参数为类名
val filterSet: MutableSet<String> = mutableSetOf()

这个页面过滤集合,可以在创建浮窗的时候就设置,也可以在需要的时候进行设置。集合数据好管理,主要是过滤功能是如何实现的。

在Application类中,ActivityLifecycleCallbacks可以实现各个Activity的生命周期监控,我们只要在特定的Activity显示时控制浮窗隐藏,在Activity不显示时再重新让浮窗显示。

同理,如果让浮窗实现仅前台显示,也可以使用此方式,当所有的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
                            }
                        }
                        // 当过滤信息没有匹配上时,需要发送广播,反之修改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处于后台时,检测是否有仅前台显示的系统浮窗
                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
                        }
                    }
                }
            }
            ... // 其他的生命周期回调
        })
    }

    private fun isForeground() = activityCount > 0

    private fun setVisible(boolean: Boolean = isForeground(), tag: String?) = FloatManager.visible(boolean, tag)
}

不过使用该生命周期监控,需要我们传入Application,即在项目的Application中需要进行浮窗的初始化;如果没使用到过滤和仅前台显示,则不需要。

实施阶段也就说这么多吧,其他一些点和一些注意细节,都在代码中,感兴趣的可以去看下。

使用:上手体验

说了这么多,到底好不好用呐?我们写个最简单的浮窗:

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

对,没有看错,一行代码就可以创建一个拖拽浮窗,默认只在当页显示。

作为结束,我们从上图中挑一个来实现。由于浮窗只支持拖拽,不支持缩放,那我们就选那个支持缩放的系统浮窗吧:


上图中一共包含了这几个属性:设置仅前台显示、过滤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()
}

需要指出的是,这里的拖拽缩放不包含在轮子中,在示例代码里。我们一块看下是怎么实现的,如有需要参考示例:

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
    }
}

逻辑很简单,只是记录手指相对于按下时的滑动距离,外部根据这个距离差值,从新设置控件大小。关键一点要屏蔽掉浮窗的事件拦截,不然接收不到触摸事件。


文章到这里就已经全部结束了,非常感谢大家的阅读。
轮子已上传到GitHub,希望对大家有所帮助,如果能收获个Star,那也最开心不过了。

项目地址:https://github.com/princekin-f/EasyFloat

特别感谢:Android 悬浮窗权限各机型各系统适配大全

说在后面:
系统浮窗的管理原先使用的是Service,坑神多!借鉴别人的同时,也应保持质疑和思考……

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,358评论 0 17
  • 其实写东西不仅仅需要灵感,还需要感觉吧。 刚回到家不久,把今天买的东西全部放在椅子上,整个人就累到不行了。 想起之...
    欢欢不是一条狗阅读 278评论 0 0
  • 加密算法与SSL及创建私有CA 标签(空格分隔): Linux 运维 加密解密 算法 三个维度验证数据 机密性: ...
    uangianlap阅读 1,030评论 0 0
  • 如果人生 是一场修行 那么热爱 就是你修行的 拐杖
    咖啡猫的故事阅读 174评论 0 0
  • 今日复盘: 1 今日待完成看书半小时,回家路上完成。 2 今日试验了 蜗牛睡眠,结果发现并不靠谱,并不会真的有深度...
    咩咩妈阅读 153评论 0 0