Android 悬浮窗功能的实现
前言
我们大多数在两种情况下可以看到悬浮窗,一个是视频通话时的悬浮窗,另一个是360卫士的悬浮球,实现此功能的方式比较多,这里以视频通话悬浮窗中的需求为例。编码实现使用Kotlin。Java版本留言邮箱即可。
业务场景
以微信视频通话为例,在视频通话时,我们打开其他应用或点击Home键退出时或点击缩放图标,悬浮窗会显示在其他应用之上,给人的假象是通话页面变小了,点击悬浮窗回到通过页面,悬浮窗消失。退出通话页面悬浮窗消失。
业务场景技术分析
在编码之前,我们必须将流程整理好,这样更有利于编码的实现。实现一个功能如果需要10分钟,思考的时间是7分钟,编码占用的时间只是三分钟。
1.悬浮窗可以显示在其他应用或launchers之上,这个肯定需要悬浮窗权限,而悬浮窗权限属于特殊权限,所以只能通过引导用户去打开无法像危险权限那样直接申请。可以做到后台显示则说明悬浮窗是一个Service。
2.通话页面隐藏时悬浮窗显示,通话页面显示时悬浮窗隐藏,可以看出悬浮窗和Activity的生命周期相关联,所以悬浮窗的Service和通话页面的Activity是通过bind去绑定的。
3.既然Service和Activity是通过bind去绑定的,说明当悬浮窗显示的时候,通话Activity虽然不可见但仍在运行。
结合上述技术问题分析,我们倒叙一一通过编码实现
悬浮窗实现方案
- 实现效果
- 准备工作
首先我们新建一个项目,项目中有两个Activity,我们在第二个Activity编写通话模拟页面。在第二个页面的原因我们后面会讲到。
- 如何将acitivity置于后台
其实很简单,我们调用一个方法即可
moveTaskToBack(true);
这个方法的含义就是将当前的任务战置于后台,so,为什么我要在第二个Activity中实现的原因之一,因为默认的Activity的启动模式是标准模式,而上面方法会将任务栈置于后台而不是一个单独的Activity,所以我们为了显示悬浮窗时不影响操作软件的其他功能,我们要将通话页面的Activity设置为singleInstance,这样当调用上面方法的时候只是将通话页面所在的Activity栈置于后台,如果你还不了解启动模式可以移步至上一篇文章:Activity的启动模式。
我们现在在右上方的点击事件中添加上述代码,可以看到通话页面的Activity的已经在后台运行了。
- 判断是否有悬浮窗权限
点击左上角图标时,我们要先判断当前app是否有悬浮窗权限,首先我们在配置文件中添加,悬浮窗的权限。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
(很多文章标题都是悬浮窗如何绕过权限,什么设置类型为TOAST或者PHONE,我想说不可能的事,TOAST类型的虽然部分机型可以显示但是就是一个普通的TOSAT会自动消失)
那么我们如何判断是否有悬浮窗权限呢,这一块不同厂商处理方案可能不一样,这里我们用一种通用的处理方案,测试表明除了(vivo部分)无效,其他多数机型都ok。并且vivo部分机型微信通话也不会弹出提示(这我就放心了~)
fun zoom(v: View) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (!Settings.canDrawOverlays(this)) {Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->dialog.dismiss()startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)}).show()} else {moveTaskToBack(true)val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)}}
}
我们通过Settings.canDrawOverlays(this)来判断当前应用是否有悬浮窗权限,如果没有,我们弹窗提示,通过
startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
跳转到开启悬浮窗权限页面。如果悬浮窗权限已开启,直接将当前任务栈置于后台,开启服务即可。
其实回调方法,并没有直接告诉我们是否授权成功,所以我们需要在回调中再次判断
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {if (requestCode == 0) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (!Settings.canDrawOverlays(this)) {Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()} else {Handler().postDelayed({val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)intent.putExtra("rangeTime", rangeTime)hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)moveTaskToBack(true)}, 1000)}}}
}
这里我们可以看到回调中延迟了1秒,因为测试发现某些机型反应“过快”,收到回调的时候还以为没有授权成功,其实已经成功了。
绑定Service我们需要一个ServiceConnection对象
internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {override fun onServiceConnected(name: ComponentName, service: IBinder) {// 获取服务的操作对象val binder = service as FloatWinfowServices.MyBinderbinder.service}override fun onServiceDisconnected(name: ComponentName) {}
}
Main2Activity的完整代码如下所示:
/*** @author Huanglinqing*/
class Main2Activity : AppCompatActivity() {private val chronometer: Chronometer? = nullprivate var hasBind = falseprivate val rangeTime: Long = 0override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main2)}fun zoom(v: View) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (!Settings.canDrawOverlays(this)) {Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->dialog.dismiss()startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)}).show()} else {moveTaskToBack(true)val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)}}}internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {override fun onServiceConnected(name: ComponentName, service: IBinder) {// 获取服务的操作对象val binder = service as FloatWinfowServices.MyBinderbinder.service}override fun onServiceDisconnected(name: ComponentName) {}}override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {if (requestCode == 0) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (!Settings.canDrawOverlays(this)) {Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()} else {Handler().postDelayed({val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)intent.putExtra("rangeTime", rangeTime)hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)moveTaskToBack(true)}, 1000)}}}}override fun onRestart() {super.onRestart()Log.d("RemoteView", "重新显示了")//不显示悬浮框if (hasBind) {unbindService(mVideoServiceConnection)hasBind = false}}override fun onNewIntent(intent: Intent) {super.onNewIntent(intent)}override fun onDestroy() {super.onDestroy()}
}
- 新建悬浮窗Service
新建悬浮窗Service FloatWinfowServices,因为我们使用的BindService,我们在onBind方法中初始化service中的布局
override fun onBind(intent: Intent): IBinder? {initWindow()//悬浮框点击事件的处理initFloating()return MyBinder()
}
service中我们通过WindowManager来添加一个布局显示。
/*** 初始化窗口*/
private fun initWindow() {winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager//设置好悬浮窗的参数wmParams = params// 悬浮窗默认显示以左上角为起始坐标wmParams!!.gravity = Gravity.LEFT or Gravity.TOP//悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0wmParams!!.x = winManager!!.defaultDisplay.widthwmParams!!.y = 210//得到容器,通过这个inflater来获得悬浮窗控件inflater = LayoutInflater.from(applicationContext)// 获取浮动窗口视图所在布局mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)// 添加悬浮窗的视图winManager!!.addView(mFloatingLayout, wmParams)
}
悬浮窗的参数主要设置悬浮窗的类型为
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
8.0 以下可设置为:
wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
代码如下所示:
private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上//设置可以显示在状态栏上//设置悬浮窗口长宽数据
val params: WindowManager.LayoutParamsget() {wmParams = WindowManager.LayoutParams()if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY} else {wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE}wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL orWindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR orWindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCHwmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENTwmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENTreturn wmParams}
当点击悬浮窗的时候回到Activity2页面,并且悬浮窗消失,所以我们只需要给悬浮窗添加点击事件
linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }
当Service走到onDestory的时候将view移除,对于Activity2页面来说 当onResume的时候 解绑Service,当onstop的时候 绑定Service。
从效果图中我们可以看到悬浮窗可以拖拽的,所以还要设置触摸事件,当移动距离超过某个值的时候让onTouch消费事件,这样就不会触发点击事件了。这个算是view比较基础的知识,相信大家都明白了。
//开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
private var mTouchStartX: Int = 0
private var mTouchStartY: Int = 0
private var mTouchCurrentX: Int = 0
private var mTouchCurrentY: Int = 0
//开始时的坐标和结束时的坐标(相对于自身控件的坐标)
private var mStartX: Int = 0
private var mStartY: Int = 0
private var mStopX: Int = 0
private var mStopY: Int = 0
//判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
private var isMove: Boolean = falseprivate inner class FloatingListener : View.OnTouchListener {override fun onTouch(v: View, event: MotionEvent): Boolean {val action = event.actionwhen (action) {MotionEvent.ACTION_DOWN -> {isMove = falsemTouchStartX = event.rawX.toInt()mTouchStartY = event.rawY.toInt()mStartX = event.x.toInt()mStartY = event.y.toInt()}MotionEvent.ACTION_MOVE -> {mTouchCurrentX = event.rawX.toInt()mTouchCurrentY = event.rawY.toInt()wmParams!!.x += mTouchCurrentX - mTouchStartXwmParams!!.y += mTouchCurrentY - mTouchStartYwinManager!!.updateViewLayout(mFloatingLayout, wmParams)mTouchStartX = mTouchCurrentXmTouchStartY = mTouchCurrentY}MotionEvent.ACTION_UP -> {mStopX = event.x.toInt()mStopY = event.y.toInt()if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {isMove = true}}else -> {}}//如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件return isMove}
}
FloatWinfowServices所有代码如下所示:
class FloatWinfowServices : Service() {private var winManager: WindowManager? = nullprivate var wmParams: WindowManager.LayoutParams? = nullprivate var inflater: LayoutInflater? = null//浮动布局private var mFloatingLayout: View? = nullprivate var linearLayout: LinearLayout? = nullprivate var chronometer: Chronometer? = nulloverride fun onBind(intent: Intent): IBinder? {initWindow()//悬浮框点击事件的处理initFloating()return MyBinder()}inner class MyBinder : Binder() {val service: FloatWinfowServicesget() = this@FloatWinfowServices}override fun onCreate() {super.onCreate()}/*** 悬浮窗点击事件*/private fun initFloating() {linearLayout = mFloatingLayout!!.findViewById<LinearLayout>(R.id.line1)linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }//悬浮框触摸事件,设置悬浮框可拖动linearLayout!!.setOnTouchListener(FloatingListener())}//开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)private var mTouchStartX: Int = 0private var mTouchStartY: Int = 0private var mTouchCurrentX: Int = 0private var mTouchCurrentY: Int = 0//开始时的坐标和结束时的坐标(相对于自身控件的坐标)private var mStartX: Int = 0private var mStartY: Int = 0private var mStopX: Int = 0private var mStopY: Int = 0//判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件private var isMove: Boolean = falseprivate inner class FloatingListener : View.OnTouchListener {override fun onTouch(v: View, event: MotionEvent): Boolean {val action = event.actionwhen (action) {MotionEvent.ACTION_DOWN -> {isMove = falsemTouchStartX = event.rawX.toInt()mTouchStartY = event.rawY.toInt()mStartX = event.x.toInt()mStartY = event.y.toInt()}MotionEvent.ACTION_MOVE -> {mTouchCurrentX = event.rawX.toInt()mTouchCurrentY = event.rawY.toInt()wmParams!!.x += mTouchCurrentX - mTouchStartXwmParams!!.y += mTouchCurrentY - mTouchStartYwinManager!!.updateViewLayout(mFloatingLayout, wmParams)mTouchStartX = mTouchCurrentXmTouchStartY = mTouchCurrentY}MotionEvent.ACTION_UP -> {mStopX = event.x.toInt()mStopY = event.y.toInt()if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {isMove = true}}else -> {}}//如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件return isMove}}/*** 初始化窗口*/private fun initWindow() {winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager//设置好悬浮窗的参数wmParams = params// 悬浮窗默认显示以左上角为起始坐标wmParams!!.gravity = Gravity.LEFT or Gravity.TOP//悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0wmParams!!.x = winManager!!.defaultDisplay.widthwmParams!!.y = 210//得到容器,通过这个inflater来获得悬浮窗控件inflater = LayoutInflater.from(applicationContext)// 获取浮动窗口视图所在布局mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)chronometer = mFloatingLayout!!.findViewById<Chronometer>(R.id.chronometer)chronometer!!.start()// 添加悬浮窗的视图winManager!!.addView(mFloatingLayout, wmParams)}private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上//设置可以显示在状态栏上//设置悬浮窗口长宽数据val params: WindowManager.LayoutParamsget() {wmParams = WindowManager.LayoutParams()if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY} else {wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE}wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL orWindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR orWindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCHwmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENTwmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENTreturn wmParams}override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {return super.onStartCommand(intent, flags, startId)}override fun onDestroy() {super.onDestroy()winManager!!.removeView(mFloatingLayout)}
}
实际应用中需要考虑的一些其他问题
在使用使用的过程中,我们肯定会遇到其他问题:
1.用户使用过程中,可能会直接按Home键,这个时候如何提示呢?
产生问题原因:因为用户按Home键之后,开发者无法重写Home键逻辑,此时应用不在前台运行,无法弹窗提醒,此时用户点击APP图标进入的是第一个栈,这个时候用户就没有进入通话页面的入口了。
解决方案:
第一种解决方案 我们可以仿照微信那样去做,就是在整个通话过程中开启一个前台通知,用户点击通知时进入通话页面。
第二种解决方案 就是检测应用是否在前台,当通话页面在运行的时候,并且应用重新回到前台,我们广播到其他页面,提示权限引导即可。
2.用户在通话页面(singleInstance模式),点击Home键
应用在后台运行的时候,通话结束,Activity被finish,此时从任务程序中切回应用你会发现打开的竟然是通话页面!
这个问题简单的说就是,如果你在通话页面呼叫某人,通话过程中按Home键,然后电话挂断,此时你从任务程序中切回应用,会再次呼叫这个人,也就是这种状态下重新回到了onCreate方法。
问题产生原因:
1.因为通话页面是singleInstance模式,此时有两个任务栈,按Home键后再从任务程序中切回,此时应用只保留了第二个任务栈,已经失去了和第一个任务栈的关系,finish之后无法在回到第一个任务栈。
解决方案:
1.(不推荐)通话页面不使用singleInstance模式,这种情况下,在通话过程中无法操作软件的其他功能,一般都不采取。
2.(我目前的解决方案)设置一个标记位,标记当前是否在通话,在onCreate中如果通话已经结束了,跳转到一个过渡页面(标准模式),过渡页面中finish,就可以了,添加过渡页面的原因是我们不知道上一个页面是哪里,因为我们收到来电可能是任意页面,我们我们在过渡页面finsh之后,就再次回到了第一个任务栈。
如果有其他好的解决方案 欢迎留言。
如果需要Java版本的小伙伴 ,留言邮箱就可以了,我看到会发到邮箱哦!
-------2020年6月2日更新------
Java版本源码已提交至github
GitHub - huanglinqing123/RemoteView: Android 悬浮窗,视频通话缩放最小
欢迎start 和Issues
Android 悬浮窗功能的实现相关推荐
- Android 悬浮窗功能实现(微信语音通话悬浮窗效果实现)
目录 1.基本介绍 2.代码示例 3.实现效果及便捷工具类 4.仿微信语音通话悬浮窗效果实现 4.1 需求分析及效果展示 4.2 实现 5.最后 1.基本介绍 Android 界面绘制都是通过 Win ...
- Android展开悬浮窗功能,Android 悬浮窗 (附圆形菜单悬浮窗)
序言 Android悬浮窗的实现,主要有四个步骤: 1. 声明及申请权限 2. 构建悬浮窗需要的控件 3. 将控件添加到WindowManager 4. 必要时更新WindowManager的布局 一 ...
- Android 悬浮窗语音识别功能开发详解
笔者是一个普通不能再普通的程序员,本着出处兴趣,花时间研究了一下,想实现手机的悬浮窗语音识别功能,这样不影响自己其它操作的,语音识别技术是用百度云语音sdk,应该不难实现,很难实现就是核心语音识别技术 ...
- android 仿微信来电_Android 悬浮窗功能实现(微信语音通话悬浮窗效果实现)
目录 1.基本介绍 2.代码示例 3.实现效果及便捷工具类 4.仿微信语音通话悬浮窗效果实现 4.1 需求分析及效果展示 4.2 实现 5.最后 1.基本介绍 Android 界面绘制都是通过 Win ...
- Android悬浮窗的简单实现
1. 前言 现在很多应用都有小悬浮窗的功能,比如看直播的时候,通过Home键返回桌面,直播的小窗口仍可以在屏幕上显示.下面将介绍下悬浮窗的的一种简单实现方式. 2.原理 Window我们应该很熟悉,它 ...
- Android悬浮窗的实现
Android悬浮窗的实现 *本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布 本文也发布于本人的知乎文章:https://zhuanlan.zhihu.com/p/39421112 ...
- Android悬浮窗原理解析(Window)[源码]
悬浮窗,在大多数应用中还是很少见的,目前我们接触到的悬浮窗,差不多都是一些系统级的应用软件,例如:360安全卫士,腾讯手机管家等:在某些服务行业如金融,餐饮等,也会在应用中添加悬浮窗,例如:美团的偷红 ...
- Android 悬浮窗的使用(1)
悬浮窗功能基本代码: //创建悬浮窗的图片,这里当然也可以用自定义的View,这里之用了简单的图片var imageView = ImageView(this)imageView.setImageRe ...
- 安卓股票悬浮窗_Android 实现悬浮窗功能
前言 我们大多数在两种情况下可以看到悬浮窗,一个是视频通话时的悬浮窗,另一个是360卫士的悬浮球,实现此功能的方式比较多,这里以视频通话悬浮窗中的需求为例.编码实现使用Kotlin.Java版本留言邮 ...
最新文章
- Eclipse不编译解决方案
- vs内存泄露检测方法
- 6年,终拿腾讯 offer!
- linux——rpm的详细说明
- linux mkfs 源码,mkfs工具怎么移植到嵌入式平台下面
- 三丰三坐标编程基本步骤_数控车床编程,经典实例教程
- 2020年中国.NET开发者大会第二天 WorkShop
- Paint the Tree CodeForces - 1244D(看似是树,其实是条链)
- php时间操作函数总结,基于php常用函数总结(数组,字符串,时间,文件操作)
- 从硬核科幻小说《三体》中看嵌入式
- github flow
- CentOS:linux开放指定端口命令
- 《恋上数据结构第1季》动态数组实现栈
- 2020 年帮你加薪的 7 个小习惯
- 单片机项目开发一般步骤
- java数组整组处理_java – 使它漂亮:同时处理数组
- linux pdf 合并 脚本,在Linux中使用脚本结合多个pdf文件?
- 【时间序列预测】基于matlab LMS麦基玻璃时间序列预测【含Matlab源码 1443期】
- 菜鸟教程php在线编程器,菜鸟教程在线工具
- socket编程之socket()