60

EasyFloat:浮窗从未如此简单 - 简书

 4 years ago
source link: https://www.jianshu.com/p/7d1a7c82094a?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
182019.08.08 15:57:31字数 4,515阅读 8,925

应用浮窗由于良好的便捷性和拓展性,在某些场景下有着不错的交互体验。
恰巧项目需求有用到,可是逛了一圈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,坑神多!借鉴别人的同时,也应保持质疑和思考……


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK