链接: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(
var layoutId: Int? = null,
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,
var gravity: Int = 0,
var offsetPair: Pair<Int,Int> = Pair(0,0),
var locationPair: Pair<Int, Int> = Pair(0, 0),
var invokeView: OnInvokeView? = null,
var callbacks: OnFloatCallbacks? = null,
var floatAnimator: OnFloatAnimator? = DefaultAnimator(),
var appFloatAnimator: OnAppFloatAnimator? = AppFloatDefaultAnimator(),
val filterSet: MutableSet = mutableSetOf(),
internal var needShow: Boolean = true
)
属性都是一步步添加的,这里我们直接展示了最终的属性列表。
为了使用方便,我们还为每个属性设置了默认值,这样即使不配什么参数,也可以创建一个简易的浮窗。
2、写一个支持拖拽的普通控件:
前面我们有说过,拖拽功能在于重写Touch事件。所以我们就写一个自己的控件,继承自ViewGroup,这里我们使用的是FrameLayout,然后重写onTouchEvent方法:
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (event != null) updateView(event)
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)
return config.isDrag || super.onInterceptTouchEvent(event)
}
至此,我们解决了控件的拖拽问题,和子View的点击问题。
拖拽控件不仅作为Activity浮窗的壳使用,也可以作为单独的控件使用,直接在xml布局文件里包裹其他控件,就可以实现相应的拖拽效果。
系统浮窗的拖拽实现有些许的不同,主要是修改坐标的方式不同,核心思想也是一样的。这里就不进行展示了,有需要的话,可以看一下相关代码。
3、创建一个Activity浮窗:
Activity浮窗的创建相对简单,可以归纳为下面三步:
-
拖拽效果由自定义的拖拽布局实现;
-
将拖拽布局,添加到Activity的根布局;
-
再将浮窗的xml布局,添加到拖拽布局中,从而实现拖拽效果。
至于Activity根布局,就是屏幕底层FrameLayout,可通过DecorView进行获取:
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
)
shell.tag = config.floatTag ?: activity.componentName.className
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 {
if (config.locationPair == Pair(0, 0)) gravity = config.gravity
}
parentFrame.addView(shell)
val floatingView = shell.findViewById(R.id.floatingView).also {
it.config = config
it.setLayout(config.layoutId!!)
it.setOnClickListener {}
}
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 {
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 = ParentFrameLayout(context.applicationContext, config)
val floatingView = LayoutInflater.from(context.applicationContext)
.inflate(config.layoutId!!, frameLayout, true)
windowManager.addView(frameLayout, params)
frameLayout?.touchListener = object : OnFloatTouchListener {
override fun onTouch(event: MotionEvent) =
touchUtils.updateFloat(frameLayout!!, event, windowManager, params)
}
...
}
5、通过Service来管理系统浮窗:
1. 每次startService,都会调用onStartCommand方法,在该方法中通过AppFloatManager创建浮窗,并将manager添加到map集合中,方便管理;
2. 通过接收广播,管理浮窗的销毁和可见性变化;
3. 在销毁浮窗浮窗后,检测map中是否还有别的浮窗存在,如果没有别的浮窗存在,stopService。
internal class
FloatService : Service() {
companion object {
...
var floatMap = mutableMapOf()
private var config = FloatConfig()
fun startService(context: Context, floatConfig: FloatConfig) {
config = floatConfig
context.startService(Intent(context, FloatService::class.java))
}
fun checkStop(context: Context, floatTag: String?) {
if (floatMap.isNotEmpty()) floatMap.remove(floatTag)
if (floatMap.isEmpty()) context.stopService(Intent(context, FloatService::class.java))
}
...
}
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()) {
floatMap[config.floatTag!!] = AppFloatManager(this, config).apply { createFloat() }
} else {
config.callbacks?.createdResult(false, "请为系统浮窗设置不同的tag", null)
logger.w("请为系统浮窗设置不同的tag")
}
return START_NOT_STICKY
}
private fun checkTag(): Boolean {
config.floatTag = config.floatTag ?: DEFAULT_TAG
if (floatMap.isEmpty()) return true
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构建类。在我们的构建类拿到授权结果以后,根据授权情况选择继续创建浮窗,或者回调创建失败。