链接:https://www.jianshu.com/p/7d1a7c82094a
应用浮窗由于良好的便捷性和拓展性,在某些场景下有着不错的交互体验。 恰巧项目需求有用到,可是逛了一圈GitHub,并没有找到满意的浮窗控件。 索性造个好用的轮子,方便你我他,遂成此文。 GitHub地址:
https://github.com/princekin-f/EasyFloat
要能浮在某个单独的页面上,或者多个页面上; 要支持拖拽,这样才够灵活; 可能需要吸附边缘,也可能不需要吸附; 要支持浮窗内部的点击、拖拽; 要灵活的控制浮窗的显示、隐藏、销毁等; 要能够自行设定出入动画,这样才够炫酷、个性; 要能够过滤不需要显示的页面; 要能够指定位置、设置对齐方式和偏移量; 权限管理要简单,能不需要最好; 要能有各个状态的监测、方便拓展; 还得使用方便、兼容性要强; 反正想要的很多...
这么多需求,应该能满足非极端使用场景了。可是这么多需求,我们需要如何一步步实现呐? 1、如何浮在其他视图之上:我们知道想要把View浮在其他视图之上,有两种实现方式: 添加到Activity根布局相对比较简单,也不需要额外的权限。可是最大的问题是跟随Activity生命周期,只能在当前Activity显示。 Window窗口则能很好的解决全局显示的问题,可是在Android 6.0之后(特殊机型除外),使用TYPE_APPLICATION_OVERLAY属性,需要进行悬浮窗权限的申请,必须手动授权。如果我们只需要在当前页面使用浮窗功能,又会觉得太重,使用不方便。 那我们改如何抉择两者? 答案:都用,根据浮窗类型使用不同的创建方式。 2、怎么拖拽、怎么设置View:既然要实现拖拽,肯定要从Touch事件下手,是单纯的onTouchEvent重写,还是要结合onInterceptTouchEvent作操作,我们后面再细说。但无论我们是以哪种方式创建的浮窗,都可以通过Touch事件实现拖拽效果,只是一些实现细节的不同。 既然说两种浮窗的拖拽过程,有些许不同,那我们最好不要把自定义的拖拽View放在xml的根节点。因为那样我们写布局文件的时候,还需要进行区分;所以我们把拖拽View作为壳,放在浮窗控件的内部,我们只需设置要展示的xml布局,然后将xml布局添加到拖拽壳里面,各司其职。 3、系统浮窗生命周期很长,如何创建、如何管理:由于系统浮窗是作为全局使用的,生命周期很长。如果直接在Activity创建,当遇到Activity被销毁时,这时的浮窗将是不可控的,满足不了我们的需求啊。 怎么办呐?我们可以选择Service,通过StartService启动一个浮窗Service,通过这个Service专门用来管理系统浮窗。 由于StartService启动的Service,不受启动者声明周期的影响,使用场景更广泛;想要控制浮窗的显示、隐藏、销毁,也只需用发送动态广播,在Service内部进行相应的广播接收,并做出处理即可。 4、如果只要前台显示、或者有页面不需要显示怎么办:想要只在前台显示,我们首先要做的就是获取前后台的状态,这个应该怎么做呐? 我们可以通过ActivityLifecycleCallbacks感知各个Activity的生命周期,通过计算打开和关闭Activity的数目,就可以知道当前APP处于前台还是后台;然后根据前后台发广播控制浮窗显示或者隐藏。 同理,有需要过滤的Activity,我们只需要监听它的生命周期变化,然后去控制显示和隐藏就好了。 5、我们需要出入动画,还不想每个都一样:学过策略模式的都应该知道,只要实现相应的接口或者复写抽象方法,就可以去做你想要的结果。 我们把入场动画、退场动画的方法,定义在策略基类中;稍加操作,应有尽有...
分析过程就阐述这么多吧,这里进行了粗略的逻辑整理,我们一起看下: 
说一千道一万,还是图片来的更直观,那有没有更直观的呐?
还真有,我们一起看一下效果图吧: 权限申请: 
系统浮窗: 
前台和过滤: 
扩展使用: 
效果大致就是这个样子,如果感兴趣,我们一起看看是怎么实现的... 实施:那我们动手了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 createActivityFloat(config: FloatConfig) { // 获取可拖拽浮窗的外壳 val shell = LayoutInflater.from(activity).inflate(R.layout.float_layout, parentFrame, false) // 为浮窗打上tag,如果未设置tag,使用类名作为tag shell.tag = config.floatTag ?: activity.componentName.className // 默认wrap_content,会导致子view的match_parent无效,所以手动设置params shell.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 } // 将浮窗外壳添加到根布局中 parentFrame.addView(shell)
// 获取浮窗对象,即自定义的FloatingView val floatingView = shell.findViewById<FloatingView>(R.id.floatingView).also { // 同步配置 it.config = config // 设置浮窗的布局文件,即我们传递过来的xml布局文件 it.setLayout(config.layoutId!!) // 设置空点击事件,用于接收触摸事件 it.setOnClickListener {} }
// 设置Callbacks config.callbacks?.createdResult(true, null, floatingView) }
效果就是我们创建的View浮在当前Activity上了,而且可拖拽;结束当前Activity,浮窗也就不存在了。 4、创建一个系统浮窗:前面我们有说过,体统浮窗最好在Service里创建,这里我们不考虑那么多,主要看下是如何把一个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.applicationContext, config) // 将浮窗布局文件添加到父容器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、通过Service来管理系统浮窗:1. 每次startService,都会调用onStartCommand方法,在该方法中通过AppFloatManager创建浮窗,并将manager添加到map集合中,方便管理; 2. 通过接收广播,管理浮窗的销毁和可见性变化; 3. 在销毁浮窗浮窗后,检测map中是否还有别的浮窗存在,如果没有别的浮窗存在,stopService。 internal class FloatService : Service() { companion object { ... // 一些静态常量 var floatMap = mutableMapOf<String, AppFloatManager>() private var config = FloatConfig()
/** * 开启创建浮窗的Service */ fun startService(context: Context, floatConfig: FloatConfig) { config = floatConfig context.startService(Intent(context, FloatService::class.java)) }
/** * 关闭浮窗后,检测是否需要关闭Service */ fun checkStop(context: Context, floatTag: String?) { // 先清除当条浮窗信息 if (floatMap.isNotEmpty()) floatMap.remove(floatTag) // 如有没有其他浮窗存在,关闭Service if (floatMap.isEmpty()) context.stopService(Intent(context, FloatService::class.java)) } ... // 发送广播的静态方法,外部可直接调用 }
// ***************************** Service 内部逻辑 ***************************** // 通过广播,接收一些指令(关闭浮窗、设置可见性) private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { // 过滤掉不满足的条件 if (intent.action != FLOAT_ACTION || floatMap.isNullOrEmpty()) return val tag = intent.getStringExtra(FLOAT_TAG) ?: DEFAULT_TAG when { // 关闭系统浮窗 intent.getBooleanExtra(FLOAT_DISMISS, false) -> floatMap[tag]?.exitAnim()
// 设置浮窗可见 intent.getBooleanExtra(FLOAT_VISIBLE, true) -> floatMap[tag]?.setVisible(View.VISIBLE)
// 设置浮窗不可见 else -> floatMap[tag]?.setVisible(View.GONE) } } }
override fun onCreate() { super.onCreate() // 注册动态广播接收器 registerReceiver(receiver, IntentFilter().apply { addAction(FLOAT_ACTION) }) }
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (checkTag()) { // 通过floatManager创建浮窗,并将floatManager添加到map中 floatMap[config.floatTag!!] = AppFloatManager(this, config).apply { createFloat() } } else { config.callbacks?.createdResult(false, '请为系统浮窗设置不同的tag', null) logger.w('请为系统浮窗设置不同的tag') } return START_NOT_STICKY }
/** * 检测浮窗的tag是否有效,不同的浮窗必须设置不同的tag */ private fun checkTag(): Boolean { // 如果未设置tag,设置默认tag config.floatTag = config.floatTag ?: DEFAULT_TAG // map为空使用默认值,有效 if (floatMap.isEmpty()) return true // map不为空,tag比对,存在相同的无效 floatMap.forEach { (tag, _) -> run { if (tag == config.floatTag) return false } } return true }
override fun onDestroy() { // 取消广播接收 unregisterReceiver(receiver) super.onDestroy() } }
Service的代码基本全贴出来了,毕竟它只是起到了中转和管理的作用;具体的系统浮窗功能,还是交由AppFloatManager来实现的。 既然使用Service,不要忘了在AndroidManifest.xml注册:
<service android:name='com.lzf.easyfloat.service.FloatService' />
6、系统浮窗创建前的权限管理:
即使是系统浮窗,安卓6.0之前也是不需要权限申请的,但这只是存在理想的情况下。由于安卓的碎片化严重,尤其神一样的国产手机面前,适配坑,权限适配神坑。 个人能力有限,遇到这种情况只好选择站着前人的肩膀上,Android 悬浮窗权限各机型各系统适配大全,这篇文章的解决方案还是比较全面的。所以本文的权限适配使用的此方案,但是该方案只具有适配性,不具有自主性。 https://blog.csdn.net/self_study/article/details/52859790 为了提高自主性,我们先进行权限检测;如果发现没有授权,我们通过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)。
上述三点摘抄自维基百科,简单说就是可以通过不同的实现过程,给出想要的实现结果。 如:某接口或某抽象类,包含排序算法,至于我们怎么排序:使用冒牌排序、快速排序,还是其他的排序都是可以的。 
接下来我们一起看轮子中的策略实例,由于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++ FloatService.floatMap.forEach { (tag, manager) -> 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处于后台时,检测是否有仅前台显示的系统浮窗 FloatService.floatMap.forEach { (tag, manager) -> run { if (manager.config.showPattern == ShowPattern.FOREGROUND) setVisible(tag = tag) } } } ... // 其他的生命周期回调 }) }
private fun isForeground() = activityCount > 0
private fun setVisible(boolean: Boolean = isForeground(), tag: String?) = FloatService.setVisible(application, 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(this@MainActivity, 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 悬浮窗权限各机型各系统适配大全 https://blog.csdn.net/self_study/article/details/52859790
●编号559,输入编号直达本文
●输入m获取到文章目录
|