前言
我們大多數在兩種情況下可以看到懸浮窗,一個是視頻通話時的懸浮窗,另一個是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.MyBinder
binder.service
}
override fun onServiceDisconnected(name: ComponentName) {}
}
Main2Activity的完整代碼如下所示:
/**
* @author Huanglinqing
*/
class Main2Activity : AppCompatActivity() {
private val chronometer: Chronometer? = null
private var hasBind = false
private val rangeTime: Long = 0
override 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.MyBinder
binder.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=0
wmParams!!.x = winManager!!.defaultDisplay.width
wmParams!!.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.LayoutParams
get() {
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 or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
return 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 = false
private inner class FloatingListener : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
val action = event.action
when (action) {
MotionEvent.ACTION_DOWN -> {
isMove = false
mTouchStartX = 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 - mTouchStartX
wmParams!!.y += mTouchCurrentY - mTouchStartY
winManager!!.updateViewLayout(mFloatingLayout, wmParams)
mTouchStartX = mTouchCurrentX
mTouchStartY = 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? = null
private var wmParams: WindowManager.LayoutParams? = null
private var inflater: LayoutInflater? = null
//浮動佈局
private var mFloatingLayout: View? = null
private var linearLayout: LinearLayout? = null
private var chronometer: Chronometer? = null
override fun onBind(intent: Intent): IBinder? {
initWindow()
//懸浮框點擊事件的處理
initFloating()
return MyBinder()
}
inner class MyBinder : Binder() {
val service: FloatWinfowServices
get() = 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 = 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 = false
private inner class FloatingListener : View.OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
val action = event.action
when (action) {
MotionEvent.ACTION_DOWN -> {
isMove = false
mTouchStartX = 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 - mTouchStartX
wmParams!!.y += mTouchCurrentY - mTouchStartY
winManager!!.updateViewLayout(mFloatingLayout, wmParams)
mTouchStartX = mTouchCurrentX
mTouchStartY = 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=0
wmParams!!.x = winManager!!.defaultDisplay.width
wmParams!!.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.LayoutParams
get() {
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 or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
return 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之後,就再次回到了第一個任務棧。
如果有其他好的解決方案 歡迎留言。
原文地址:https://huanglinqing.blog.csdn.net/article/details/95372212
如果需要Java版本源碼的小夥伴 ,留言郵箱就可以了,我看到會發到郵箱哦!