一、场景
在日常使用app的过程中,基本上会遇见把窗口缩小的场景出现,比如B站看视频,微信直播,微信视频通话等,主动缩小或者返回都会触发悬浮窗体的显示,如下:
b站 | 微信视频号 |
---|---|
那么实现这种效果一般的思路是怎样,可以继续往下看。
二、思考点
针对窗口缩小或者悬浮窗需要考虑几个重要的点:
- 悬浮窗体的比例以及层级,层级要在statusbar之下且在activity之上,这样才能保证其不会被其他业务界面覆盖;
- 悬浮框显示后,内部的视频内容如何无缝衔接继续显示;
三、思路
实现整个悬浮窗的核心在于WindowManager与window的交互。
WindowManager
是app与window通信的一个接口。从语义上看WindowManager是用来管理window的一个接口,那么window又是什么?其实我们常见的Dialog、Popup、StatusBar等本质就是window,window是一个抽象类,相当于一个联盟,Dialog、Popup等view只有依附在window这个联盟才能发挥功能,而WindowManager就像是联盟的会长,负责与子view等会员通信,并且能够对他们进行增加、更新和删除。
实现悬浮窗就需要配置不同的参数于属性,关于WindowManager参数、属性的详细资料可以参看juejin.cn/post/684490…
从上面描述可以知道利用addView将View添加在window上,同样的,WindowManager.LayoutParams.type可以设置View的层级,防止被其他业务界面所覆盖。
四、实现
4.1、请求悬浮窗权限
关于悬浮窗的权限,
- 当API<18时,系统默认是有悬浮窗的权限,不需要去处理;
- 当API >= 23时,需要在
AndroidManifest
中申请权限,为了防止用户手动在设置中取消权限,需要在每次使用时check一下是否有悬浮窗权限存在; - 当API > 25时,系统直接禁止用户使用TYPE_TOAST创建悬浮窗。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
/**
* 检查悬浮窗权限
* API <18,默认有悬浮窗权限,不需要处理。无法接收无法接收触摸和按键事件
* API >= 19 ,可以接收触摸和按键事件
* API >=23,需要在AndroidManifest中申请权限,为了防止用户手动在设置中取消权限,需要在每次使用时check一下是否有悬浮窗权限存在;
* API >25,系统直接禁止用户使用TYPE_TOAST创建悬浮窗。
*/
private fun requestPermission(context: Context?, op: Int): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// 6.0动态申请悬浮窗权限
if (!Settings.canDrawOverlays(context)) {
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
intent.data = Uri.parse("package:" + context!!.packageName)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
return false
}
return true
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val manager = context!!.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return try {
val method = AppOpsManager::class.java.getDeclaredMethod(
"checkOp",
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
String::class.java
)
AppOpsManager.MODE_ALLOWED == method.invoke(
manager,
op,
Binder.getCallingUid(),
context.packageName
) as Int
} catch (e: Exception) {
false
}
}
return true
}
4.2、 窗体UI
悬浮窗UI一般就两个部分,一个部分用来装载视频流,另一部分是关闭窗口的按钮,
<androidx.constraintlayout.widget.ConstraintLayout >
<RelativeLayout
android:id="@+id/rl_display_container"
android:layout_width="0dp"
android:layout_height="0dp" />
<Button
android:id="@+id/btn_close"
android:layout_width="25dp"
android:layout_height="25dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
其中RelativeLayout作为一个装载视频流的容器,在进行窗口切换时,会将视频的View 如TextureView进行添加。
4.3、初始化悬浮窗
初始化悬浮窗指的是设置其大小以及相应的层级。
从四、思路这一节中就已经说明,大小尺寸以及层级可以使用WindowManager.LayoutParams
的x
、y
、gravity
、type
等属性进行设置。
private fun initFloatWindow() {
//屏幕宽度
val screenWidth: Int = getScreenWidth()
val rect = FloatWindowRect(screenWidth - 400, 0, 400, 600)
mWindowManager = mContext?.applicationContext?.getSystemService(Context.WINDOW_SERVICE) as WindowManager
mWindowParams = WindowManager.LayoutParams()
mWindowParams?.let {
//设置层级
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
it.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
it.type = WindowManager.LayoutParams.TYPE_PHONE
}
it.flags =
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
it.gravity = Gravity.CENTER_VERTICAL
it.format = PixelFormat.TRANSLUCENT
it.x = rect.x
it.y = rect.y
it.width = rect.width
it.height = rect.height
}
}
窗体大小可以依据自身业务需要进行设定,同时可以看到将Flag属性设置为了FLAG_NOT_FOCUSABLE
和FLAG_NOT_TOUCH_MODAL
。
-
FLAG_NOT_FOCUSABLE:表示此窗口范围内的事件自己处理,范围外的事件依旧为原窗口处理;例如点击该窗口外的view,依然会有响应。另外只要设置了此Flag,都将会启用FLAG_NOT_TOUCH_MODAL,最后,设置了该Flag就表示window不会与输入方法交互,例如该window上有EditView,点击EditView是不会弹出软键盘的。
-
FLAG_NOT_TOUCH_MODAL:设置了该Flag,新window范围外的view也是可以响应touch事件。
不管是视频播放、直播还是视频通话也好,悬浮窗肯定是不能低于普通activity的层级,不然就会被覆盖,所以这里提到了一个属性:
- TYPE_APPLICATION_OVERLAY
这个层级所代表的意思是覆盖于所有activity window之上,但低于关键系统window(如状态栏,IME等),同时系统还会调整使用该窗口类型的进程,减少被低内存杀死的几率,这种恰恰符合我们悬浮窗的场景(具体层级视自身业务而定)。
4.4、显示并加载视频
fun showFloatWindow(view: View): Boolean {
if (!requestPermission(mContext, OP_SYSTEM_ALERT_WINDOW)) {
Toast.makeText(mContext, "请手动打开悬浮窗口权限", Toast.LENGTH_SHORT).show()
return false
}
try {
// 设置悬浮窗口位置和大小
val views = view as ViewGroup
val layoutParams = views.getChildAt(0).layoutParams
mWindowParams?.width = layoutParams.width
mWindowParams?.height = layoutParams.height
val parent = view.getParent() as ViewGroup
parent.removeView(view)
mLayoutDisplayContainer!!.addView(view)
mWindowManager?.addView(mViewRoot, mWindowParams)
} catch (e: Exception) {
Toast.makeText(mContext, "悬浮播放失败", Toast.LENGTH_SHORT).show()
return false
}
return true
}
当用户想要显示悬浮窗口时,直接将装载视频流的View add到4.2提到的RelativeLayout中,并且重新设置大小和位置。
4.5、悬浮窗随手指移动
悬浮窗随手指移动的逻辑直接交给View 的Touch事件,
private inner class FloatWindowOnTouchListener : OnTouchListener {
private var startX = 0
private var startY = 0
private var x = 0
private var y = 0
override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
x = event.rawX.toInt()
y = event.rawY.toInt()
startX = x
startY = y
}
MotionEvent.ACTION_MOVE -> {
val nowX = event.rawX.toInt()
val nowY = event.rawY.toInt()
val movedX = nowX - x
val movedY = nowY - y
x = nowX
y = nowY
//手指移动,重新计算x,y,并更新
mWindowParams?.x = mWindowParams?.x?.plus(movedX)
mWindowParams?.y = mWindowParams?.y?.plus(movedY)
mWindowManager?.updateViewLayout(view, mWindowParams)
}
MotionEvent.ACTION_UP -> if (Math.abs(x - startX) < 5 && Math.abs(y - startY) < 5) {
//手指没有滑动视为点击
//在这里做关闭窗口的操作
}
else -> {
}
}
return true
}
}
在DOWN事件下,记录手指点击的位置,MOVE事件中,随手指滑动,记录移动的距离重新计算x,y值并直接更新,在手指UP事件下,判断手指移动的距离,如果只是点击的话,即关闭悬浮窗,展开大屏。
关于悬浮窗的展示,思路基本上如上所述,当然,其中的一些细节需要和自身业务相结合。
本文到这里就结束了,有问题可留言评论区,咱们下篇见~
源码地址:floatWindow
推荐阅读:
Android P下WindowManager与LayoutParams的详解
探究ANR原理-是谁控制了ANR的触发时间
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。