当前位置:   article > 正文

Android 悬浮窗功能的实现_android悬浮窗实现

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部分机型微信通话也不会弹出提示(这我就放心了~)

  1. fun zoom(v: View) {
  2. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  3. if (!Settings.canDrawOverlays(this)) {
  4. Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)
  5. GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->
  6. dialog.dismiss()
  7. startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
  8. }).show()
  9. } else {
  10. moveTaskToBack(true)
  11. val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
  12. hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
  13. }
  14. }
  15. }

我们通过Settings.canDrawOverlays(this)来判断当前应用是否有悬浮窗权限,如果没有,我们弹窗提示,通过

startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)

 跳转到开启悬浮窗权限页面。如果悬浮窗权限已开启,直接将当前任务栈置于后台,开启服务即可。

其实回调方法,并没有直接告诉我们是否授权成功,所以我们需要在回调中再次判断

  1. override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
  2. if (requestCode == 0) {
  3. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  4. if (!Settings.canDrawOverlays(this)) {
  5. Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()
  6. } else {
  7. Handler().postDelayed({
  8. val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
  9. intent.putExtra("rangeTime", rangeTime)
  10. hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
  11. moveTaskToBack(true)
  12. }, 1000)
  13. }
  14. }
  15. }
  16. }

这里我们可以看到回调中延迟了1秒,因为测试发现某些机型反应“过快”,收到回调的时候还以为没有授权成功,其实已经成功了。

绑定Service我们需要一个ServiceConnection对象

  1. internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {
  2. override fun onServiceConnected(name: ComponentName, service: IBinder) {
  3. // 获取服务的操作对象
  4. val binder = service as FloatWinfowServices.MyBinder
  5. binder.service
  6. }
  7. override fun onServiceDisconnected(name: ComponentName) {}
  8. }

Main2Activity的完整代码如下所示:

  1. /**
  2. * @author Huanglinqing
  3. */
  4. class Main2Activity : AppCompatActivity() {
  5. private val chronometer: Chronometer? = null
  6. private var hasBind = false
  7. private val rangeTime: Long = 0
  8. override fun onCreate(savedInstanceState: Bundle?) {
  9. super.onCreate(savedInstanceState)
  10. setContentView(R.layout.activity_main2)
  11. }
  12. fun zoom(v: View) {
  13. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  14. if (!Settings.canDrawOverlays(this)) {
  15. Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)
  16. GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->
  17. dialog.dismiss()
  18. startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
  19. }).show()
  20. } else {
  21. moveTaskToBack(true)
  22. val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
  23. hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
  24. }
  25. }
  26. }
  27. internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {
  28. override fun onServiceConnected(name: ComponentName, service: IBinder) {
  29. // 获取服务的操作对象
  30. val binder = service as FloatWinfowServices.MyBinder
  31. binder.service
  32. }
  33. override fun onServiceDisconnected(name: ComponentName) {}
  34. }
  35. override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
  36. if (requestCode == 0) {
  37. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
  38. if (!Settings.canDrawOverlays(this)) {
  39. Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()
  40. } else {
  41. Handler().postDelayed({
  42. val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
  43. intent.putExtra("rangeTime", rangeTime)
  44. hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
  45. moveTaskToBack(true)
  46. }, 1000)
  47. }
  48. }
  49. }
  50. }
  51. override fun onRestart() {
  52. super.onRestart()
  53. Log.d("RemoteView", "重新显示了")
  54. //不显示悬浮框
  55. if (hasBind) {
  56. unbindService(mVideoServiceConnection)
  57. hasBind = false
  58. }
  59. }
  60. override fun onNewIntent(intent: Intent) {
  61. super.onNewIntent(intent)
  62. }
  63. override fun onDestroy() {
  64. super.onDestroy()
  65. }
  66. }
  • 新建悬浮窗Service

新建悬浮窗Service FloatWinfowServices,因为我们使用的BindService,我们在onBind方法中初始化service中的布局

  1. override fun onBind(intent: Intent): IBinder? {
  2. initWindow()
  3. //悬浮框点击事件的处理
  4. initFloating()
  5. return MyBinder()
  6. }

service中我们通过WindowManager来添加一个布局显示。

  1. /**
  2. * 初始化窗口
  3. */
  4. private fun initWindow() {
  5. winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
  6. //设置好悬浮窗的参数
  7. wmParams = params
  8. // 悬浮窗默认显示以左上角为起始坐标
  9. wmParams!!.gravity = Gravity.LEFT or Gravity.TOP
  10. //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0
  11. wmParams!!.x = winManager!!.defaultDisplay.width
  12. wmParams!!.y = 210
  13. //得到容器,通过这个inflater来获得悬浮窗控件
  14. inflater = LayoutInflater.from(applicationContext)
  15. // 获取浮动窗口视图所在布局
  16. mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)
  17. // 添加悬浮窗的视图
  18. winManager!!.addView(mFloatingLayout, wmParams)
  19. }

悬浮窗的参数主要设置悬浮窗的类型为

WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY

8.0 以下可设置为:

wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE

代码如下所示:

  1. private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上
  2. //设置可以显示在状态栏上
  3. //设置悬浮窗口长宽数据
  4. val params: WindowManager.LayoutParams
  5. get() {
  6. wmParams = WindowManager.LayoutParams()
  7. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  8. wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
  9. } else {
  10. wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
  11. }
  12. wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
  13. WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
  14. WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
  15. wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
  16. wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
  17. return wmParams
  18. }

当点击悬浮窗的时候回到Activity2页面,并且悬浮窗消失,所以我们只需要给悬浮窗添加点击事件

linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }

当Service走到onDestory的时候将view移除,对于Activity2页面来说 当onResume的时候 解绑Service,当onstop的时候 绑定Service。

从效果图中我们可以看到悬浮窗可以拖拽的,所以还要设置触摸事件,当移动距离超过某个值的时候让onTouch消费事件,这样就不会触发点击事件了。这个算是view比较基础的知识,相信大家都明白了。

  1. //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
  2. private var mTouchStartX: Int = 0
  3. private var mTouchStartY: Int = 0
  4. private var mTouchCurrentX: Int = 0
  5. private var mTouchCurrentY: Int = 0
  6. //开始时的坐标和结束时的坐标(相对于自身控件的坐标)
  7. private var mStartX: Int = 0
  8. private var mStartY: Int = 0
  9. private var mStopX: Int = 0
  10. private var mStopY: Int = 0
  11. //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
  12. private var isMove: Boolean = false
  13. private inner class FloatingListener : View.OnTouchListener {
  14. override fun onTouch(v: View, event: MotionEvent): Boolean {
  15. val action = event.action
  16. when (action) {
  17. MotionEvent.ACTION_DOWN -> {
  18. isMove = false
  19. mTouchStartX = event.rawX.toInt()
  20. mTouchStartY = event.rawY.toInt()
  21. mStartX = event.x.toInt()
  22. mStartY = event.y.toInt()
  23. }
  24. MotionEvent.ACTION_MOVE -> {
  25. mTouchCurrentX = event.rawX.toInt()
  26. mTouchCurrentY = event.rawY.toInt()
  27. wmParams!!.x += mTouchCurrentX - mTouchStartX
  28. wmParams!!.y += mTouchCurrentY - mTouchStartY
  29. winManager!!.updateViewLayout(mFloatingLayout, wmParams)
  30. mTouchStartX = mTouchCurrentX
  31. mTouchStartY = mTouchCurrentY
  32. }
  33. MotionEvent.ACTION_UP -> {
  34. mStopX = event.x.toInt()
  35. mStopY = event.y.toInt()
  36. if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
  37. isMove = true
  38. }
  39. }
  40. else -> {
  41. }
  42. }
  43. //如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
  44. return isMove
  45. }
  46. }

FloatWinfowServices所有代码如下所示:

  1. class FloatWinfowServices : Service() {
  2. private var winManager: WindowManager? = null
  3. private var wmParams: WindowManager.LayoutParams? = null
  4. private var inflater: LayoutInflater? = null
  5. //浮动布局
  6. private var mFloatingLayout: View? = null
  7. private var linearLayout: LinearLayout? = null
  8. private var chronometer: Chronometer? = null
  9. override fun onBind(intent: Intent): IBinder? {
  10. initWindow()
  11. //悬浮框点击事件的处理
  12. initFloating()
  13. return MyBinder()
  14. }
  15. inner class MyBinder : Binder() {
  16. val service: FloatWinfowServices
  17. get() = this@FloatWinfowServices
  18. }
  19. override fun onCreate() {
  20. super.onCreate()
  21. }
  22. /**
  23. * 悬浮窗点击事件
  24. */
  25. private fun initFloating() {
  26. linearLayout = mFloatingLayout!!.findViewById<LinearLayout>(R.id.line1)
  27. linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }
  28. //悬浮框触摸事件,设置悬浮框可拖动
  29. linearLayout!!.setOnTouchListener(FloatingListener())
  30. }
  31. //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
  32. private var mTouchStartX: Int = 0
  33. private var mTouchStartY: Int = 0
  34. private var mTouchCurrentX: Int = 0
  35. private var mTouchCurrentY: Int = 0
  36. //开始时的坐标和结束时的坐标(相对于自身控件的坐标)
  37. private var mStartX: Int = 0
  38. private var mStartY: Int = 0
  39. private var mStopX: Int = 0
  40. private var mStopY: Int = 0
  41. //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
  42. private var isMove: Boolean = false
  43. private inner class FloatingListener : View.OnTouchListener {
  44. override fun onTouch(v: View, event: MotionEvent): Boolean {
  45. val action = event.action
  46. when (action) {
  47. MotionEvent.ACTION_DOWN -> {
  48. isMove = false
  49. mTouchStartX = event.rawX.toInt()
  50. mTouchStartY = event.rawY.toInt()
  51. mStartX = event.x.toInt()
  52. mStartY = event.y.toInt()
  53. }
  54. MotionEvent.ACTION_MOVE -> {
  55. mTouchCurrentX = event.rawX.toInt()
  56. mTouchCurrentY = event.rawY.toInt()
  57. wmParams!!.x += mTouchCurrentX - mTouchStartX
  58. wmParams!!.y += mTouchCurrentY - mTouchStartY
  59. winManager!!.updateViewLayout(mFloatingLayout, wmParams)
  60. mTouchStartX = mTouchCurrentX
  61. mTouchStartY = mTouchCurrentY
  62. }
  63. MotionEvent.ACTION_UP -> {
  64. mStopX = event.x.toInt()
  65. mStopY = event.y.toInt()
  66. if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
  67. isMove = true
  68. }
  69. }
  70. else -> {
  71. }
  72. }
  73. //如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
  74. return isMove
  75. }
  76. }
  77. /**
  78. * 初始化窗口
  79. */
  80. private fun initWindow() {
  81. winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
  82. //设置好悬浮窗的参数
  83. wmParams = params
  84. // 悬浮窗默认显示以左上角为起始坐标
  85. wmParams!!.gravity = Gravity.LEFT or Gravity.TOP
  86. //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0
  87. wmParams!!.x = winManager!!.defaultDisplay.width
  88. wmParams!!.y = 210
  89. //得到容器,通过这个inflater来获得悬浮窗控件
  90. inflater = LayoutInflater.from(applicationContext)
  91. // 获取浮动窗口视图所在布局
  92. mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)
  93. chronometer = mFloatingLayout!!.findViewById<Chronometer>(R.id.chronometer)
  94. chronometer!!.start()
  95. // 添加悬浮窗的视图
  96. winManager!!.addView(mFloatingLayout, wmParams)
  97. }
  98. private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上
  99. //设置可以显示在状态栏上
  100. //设置悬浮窗口长宽数据
  101. val params: WindowManager.LayoutParams
  102. get() {
  103. wmParams = WindowManager.LayoutParams()
  104. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
  105. wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
  106. } else {
  107. wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
  108. }
  109. wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
  110. WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
  111. WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
  112. wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
  113. wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
  114. return wmParams
  115. }
  116. override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
  117. return super.onStartCommand(intent, flags, startId)
  118. }
  119. override fun onDestroy() {
  120. super.onDestroy()
  121. winManager!!.removeView(mFloatingLayout)
  122. }
  123. }
  • 实际应用中需要考虑的一些其他问题

在使用使用的过程中,我们肯定会遇到其他问题:

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

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/500756
推荐阅读
相关标签
  

闽ICP备14008679号