Android懸浮窗怎麼簡單實現?這樣用 kotlin編寫輕鬆搞定!
本文以業務應用為出發點,從零開始抽象一個浮窗工具類,它用於在任意業務介面上展示懸浮窗。它可以同時管理多個浮窗,而且浮窗可以響應觸控事件,可拖拽,有貼邊動畫。
文中例項程式碼使用 kotlin 編寫,kotlin 系列教程可以點選 這裡
效果如下:
顯示浮窗
原生
ViewManager
介面提供了向視窗新增並操縱
View
的方法:
public interface ViewManager{ //'向視窗新增檢視' public void addView(View view, ViewGroup.LayoutParams params); //'更新視窗中檢視' public void updateViewLayout(View view, ViewGroup.LayoutParams params); //'移除視窗中檢視' public void removeView(View view); } 複製程式碼
使用這個介面顯示視窗的模版程式碼如下:
//'解析佈局檔案為檢視'val windowView = LayoutInflater.from(context).inflate(R.id.window_view, null)//'獲取WindowManager系統服務'val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager//'構建視窗布局引數'WindowManager.LayoutParams().apply { type = WindowManager.LayoutParams.TYPE_APPLICATION width = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT gravity = Gravity.START or Gravity.TOP x = 0 y = 0}.let { layoutParams-> //'將檢視新增到視窗' windowManager.addView(windowView, layoutParams) } 複製程式碼
- 上述程式碼在當前介面的左上角顯示
R.id.window_view.xml
中定義的佈局。 - 為避免重複,將這段程式碼抽象成一個函式,其中視窗檢視內容和展示位置會隨著需求而變,遂將其引數化:
object FloatWindow{ private var context: Context? = null //'當前視窗引數' var windowInfo: WindowInfo? = null //'把和Window佈局有關的引數打包成一個內部類' class WindowInfo(var view: View?) { var layoutParams: WindowManager.LayoutParams? = null //'視窗寬' var width: Int = 0 //'視窗高' var height: Int = 0 //'視窗中是否有檢視' fun hasView() = view != null && layoutParams != null //'視窗中檢視是否有父親' fun hasParent() = hasView() && view?.parent != null } //'顯示視窗' fun show( context: Context, windowInfo: WindowInfo?, x: Int = windowInfo?.layoutParams?.x.value(), y: Int = windowInfo?.layoutParams?.y.value(), ) { if (windowInfo == null) { return } if (windowInfo.view == null) { return } this.windowInfo = windowInfo this.context = context //'建立視窗布局引數' windowInfo.layoutParams = createLayoutParam(x, y) //'顯示視窗' if (!windowInfo.hasParent().value()) { val windowManager = this.context?.getSystemService(Context.WINDOW_SERVICE) as WindowManager windowManager.addView(windowInfo.view, windowInfo.layoutParams) } } //'建立視窗布局引數' private fun createLayoutParam(x: Int, y: Int): WindowManager.LayoutParams { if (context == null) { return WindowManager.LayoutParams() } return WindowManager.LayoutParams().apply { //'該型別不需要申請許可權' type = WindowManager.LayoutParams.TYPE_APPLICATION format = PixelFormat.TRANSLUCENT flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS gravity = Gravity.START or Gravity.TOP width = windowInfo?.width.value() height = windowInfo?.height.value() this.x = x this.y = y } } //'為空Int提供預設值' fun Int?.value() = this ?: 0} 複製程式碼
- 將
FloatWindow
宣告成了單例,目的是在 app 整個生命週期,任何介面都可以方便地顯示浮窗。 - 為了方便統一管理視窗的引數,抽象了內部類
WindowInfo
- 現在就可以像這樣在螢幕左上角顯示一個浮窗了:
val windowView = LayoutInflater.from(context).inflate(R.id.window_view, null) WindowInfo(windowView).apply{ width = 100 height = 100 }.let{ windowInfo -> FloatWindow.show(context, windowInfo, 0, 0) } 複製程式碼
浮窗背景色
產品要求當浮窗顯示時,螢幕變暗。設定
WindowManager.LayoutParams.FLAG_DIM_BEHIND
標籤配合
dimAmount
就能輕鬆實現:
object FloatWindow{ //當前視窗引數 var windowInfo: WindowInfo? = null private fun createLayoutParam(x: Int, y: Int): WindowManager.LayoutParams { if (context == null) { return WindowManager.LayoutParams() } return WindowManager.LayoutParams().apply { type = WindowManager.LayoutParams.TYPE_APPLICATION format = PixelFormat.TRANSLUCENT flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or //'設定浮窗背景變暗' WindowManager.LayoutParams.FLAG_DIM_BEHIND //'設定預設變暗程度為0,即不變暗,1表示全黑' dimAmount = 0f gravity = Gravity.START or Gravity.TOP width = windowInfo?.width.value() height = windowInfo?.height.value() this.x = x this.y = y } } //'供業務介面在需要的時候調整浮窗背景亮暗' fun setDimAmount(amount:Float){ windowInfo?.layoutParams?.let { it.dimAmount = amount } } } 複製程式碼
設定浮窗點選事件
為浮窗設定點選事件等價於為浮窗檢視設定點選事件,但如果直接對浮窗檢視使用
setOnClickListener()
的話,浮窗的觸控事件就不會被響應,那拖拽就無法實現。所以只能從更底層的觸控事件著手:
object FloatWindow : View.OnTouchListener{ //'顯示視窗' fun show( context: Context, windowInfo: WindowInfo?, x: Int = windowInfo?.layoutParams?.x.value(), y: Int = windowInfo?.layoutParams?.y.value(), ) { if (windowInfo == null) { return } if (windowInfo.view == null) { return } this.windowInfo = windowInfo this.context = context //'為浮窗檢視設定觸控監聽器' windowInfo.view?.setOnTouchListener(this) windowInfo.layoutParams = createLayoutParam(x, y) if (!windowInfo.hasParent().value()) { val windowManager = this.context?.getSystemService(Context.WINDOW_SERVICE) as WindowManager windowManager.addView(windowInfo.view, windowInfo.layoutParams) } } override fun onTouch(v: View, event: MotionEvent): Boolean { return false } } 複製程式碼
- 在
onTouch(v: View, event: MotionEvent)
中可以拿到更詳細的觸控事件,比如ACTION_DOWN
,ACTION_MOVE
、ACTION_UP
。這方便了拖拽的實現,但點選事件的捕獲變得複雜,因為需要定義上述三個 ACTION 以怎樣的序列出現時才判定為點選事件。幸好GestureDetector
為我們做了這件事:
public class GestureDetector { public interface OnGestureListener { //'ACTION_DOWN事件' boolean onDown(MotionEvent e); //'單擊事件' boolean onSingleTapUp(MotionEvent e); //'拖拽事件' boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY); ... } } 複製程式碼
構建
GestureDetector
例項並將
MotionEvent
傳遞給它就能將觸控事件解析成感興趣的上層事件:
object FloatWindow : View.OnTouchListener{ private var gestureDetector: GestureDetector = GestureDetector(context, GestureListener()) private var clickListener: WindowClickListener? = null private var lastTouchX: Int = 0 private var lastTouchY: Int = 0 //'為浮窗設定點選監聽器' fun setClickListener(listener: WindowClickListener) { clickListener = listener } override fun onTouch(v: View, event: MotionEvent): Boolean { //'將觸控事件傳遞給 GestureDetector 解析' gestureDetector.onTouchEvent(event) return true } //'記憶起始觸控點座標' private fun onActionDown(event: MotionEvent) { lastTouchX = event.rawX.toInt() lastTouchY = event.rawY.toInt() } private class GestureListener : GestureDetector.OnGestureListener { //'記憶起始觸控點座標' override fun onDown(e: MotionEvent): Boolean { onActionDown(e) return false } override fun onSingleTapUp(e: MotionEvent): Boolean { //'點選事件發生時,呼叫監聽器' return clickListener?.onWindowClick(windowInfo) ?: false } ... } //'浮窗點選監聽器' interface WindowClickListener { fun onWindowClick(windowInfo: WindowInfo?): Boolean } } 複製程式碼
拖拽浮窗
ViewManager
提供了
updateViewLayout(View view, ViewGroup.LayoutParams params)
用於更新浮窗位置,所以只需監聽
ACTION_MOVE
事件並實時更新浮窗檢視位置就可實現拖拽。
ACTION_MOVE
事件被
GestureDetector
解析成
OnGestureListener.onScroll()
回撥:
object FloatWindow : View.OnTouchListener{ private var gestureDetector: GestureDetector = GestureDetector(context, GestureListener()) private var lastTouchX: Int = 0 private var lastTouchY: Int = 0 override fun onTouch(v: View, event: MotionEvent): Boolean { //'將觸控事件傳遞給GestureDetector解析' gestureDetector.onTouchEvent(event) return true } private class GestureListener : GestureDetector.OnGestureListener { override fun onDown(e: MotionEvent): Boolean { onActionDown(e) return false } override fun onScroll(e1: MotionEvent,e2: MotionEvent,distanceX: Float,distanceY:Float): Boolean { //'響應手指滾動事件' onActionMove(e2) return true } } private fun onActionMove(event: MotionEvent) { //'獲取當前手指座標' val currentX = event.rawX.toInt() val currentY = event.rawY.toInt() //'獲取手指移動增量' val dx = currentX - lastTouchX val dy = currentY - lastTouchY //'將移動增量應用到視窗布局引數上' windowInfo?.layoutParams!!.x += dx windowInfo?.layoutParams!!.y += dy val windowManager = context?.getSystemService(Context.WINDOW_SERVICE) as WindowManager var rightMost = screenWidth - windowInfo?.layoutParams!!.width var leftMost = 0 val topMost = 0 val bottomMost = screenHeight - windowInfo?.layoutParams!!.height - getNavigationBarHeight(context) //'將浮窗移動區域限制在螢幕內' if (windowInfo?.layoutParams!!.x < leftMost) { windowInfo?.layoutParams!!.x = leftMost } if (windowInfo?.layoutParams!!.x > rightMost) { windowInfo?.layoutParams!!.x = rightMost } if (windowInfo?.layoutParams!!.y < topMost) { windowInfo?.layoutParams!!.y = topMost } if (windowInfo?.layoutParams!!.y > bottomMost) { windowInfo?.layoutParams!!.y = bottomMost } //'更新浮窗位置' windowManager.updateViewLayout(windowInfo?.view, windowInfo?.layoutParams) lastTouchX = currentX lastTouchY = currentY } } 複製程式碼
浮窗自動貼邊
新的需求來了,拖拽浮窗鬆手後,需要自動貼邊。
把貼邊理解成一個水平位移動畫。在鬆手時求出動畫起點和終點橫座標,利用動畫值不斷更新浮窗位置::
object FloatWindow : View.OnTouchListener{ private var gestureDetector: GestureDetector = GestureDetector(context, GestureListener()) private var lastTouchX: Int = 0 private var lastTouchY: Int = 0 //'貼邊動畫' private var weltAnimator: ValueAnimator? = null override fun onTouch(v: View, event: MotionEvent): Boolean { //'將觸控事件傳遞給GestureDetector解析' gestureDetector.onTouchEvent(event) //'處理ACTION_UP事件' val action = event.action when (action) { MotionEvent.ACTION_UP -> onActionUp(event, screenWidth, windowInfo?.width ?: 0) else -> { } } return true } private fun onActionUp(event: MotionEvent, screenWidth: Int, width: Int) { if (!windowInfo?.hasView().value()) { return } //'記錄抬手橫座標' val upX = event.rawX.toInt() //'貼邊動畫終點橫座標' val endX = if (upX > screenWidth / 2) { screenWidth - width } else { 0 } //'構建貼邊動畫' if (weltAnimator == null) { weltAnimator = ValueAnimator.ofInt(windowInfo?.layoutParams!!.x, endX).apply { interpolator = LinearInterpolator() duration = 300 addUpdateListener { animation -> val x = animation.animatedValue as Int if (windowInfo?.layoutParams != null) { windowInfo?.layoutParams!!.x = x } val windowManager = context?.getSystemService(Context.WINDOW_SERVICE) as WindowManager //'更新視窗位置' if (windowInfo?.hasParent().value()) { windowManager.updateViewLayout(windowInfo?.view, windowInfo?.layoutParams) } } } } weltAnimator?.setIntValues(windowInfo?.layoutParams!!.x, endX) weltAnimator?.start() } //為空Boolean提供預設值 fun Boolean?.value() = this ?: false} 複製程式碼
-
GestureDetector
解析後ACTION_UP
事件被吞掉了,所以只能在onTouch()
中截獲它。 - 根據抬手橫座標和螢幕中點橫座標的大小關係,來決定浮窗貼向左邊還是右邊。
管理多個浮窗
若 app 的不同業務介面同時需要顯示浮窗:進入 介面A 時顯示 浮窗A,然後它被拖拽到右下角,退出 介面A 進入 介面B,顯示浮窗B,當再次進入 介面A 時,期望還原上次離開時的浮窗A的位置。
當前
FloatWindow
中用
windowInfo
成員儲存單個浮窗引數,為了同時管理多個浮窗,需要將所有浮窗引數儲存在
Map
結構中用 tag 區分:
object FloatWindow : View.OnTouchListener { //'浮窗引數容器' private var windowInfoMap: HashMap<String, WindowInfo?> = HashMap() //'當前浮窗引數' var windowInfo: WindowInfo? = null //'顯示浮窗' fun show( context: Context, //'浮窗標籤' tag: String, //'若不提供浮窗引數則從引數容器中獲取該tag上次儲存的引數' windowInfo: WindowInfo? = windowInfoMap[tag], x: Int = windowInfo?.layoutParams?.x.value(), y: Int = windowInfo?.layoutParams?.y.value() ) { if (windowInfo == null) { return } if (windowInfo.view == null) { return } //'更新當前浮窗引數' this.windowInfo = windowInfo //'將浮窗引數存入容器' windowInfoMap[tag] = windowInfo windowInfo.view?.setOnTouchListener(this) this.context = context windowInfo.layoutParams = createLayoutParam(x, y) if (!windowInfo.hasParent().value()) { val windowManager =this.context?.getSystemService(Context.WINDOW_SERVICE) as WindowManager windowManager.addView(windowInfo.view, windowInfo.layoutParams) } } } 複製程式碼
在顯示浮窗時,增加
tag
標籤引數用以唯一標識浮窗,並且為
windowInfo
提供預設引數,當恢復原有浮窗時,可以不提供
windowInfo
引數,
FloatWindow
就會去
windowInfoMap
中根據給定
tag
尋找對應
windowInfo
。
監聽浮窗界外點選事件
新的需求來了,點選浮窗時,貼邊的浮窗像抽屜一樣展示,點選浮窗以外區域時,抽屜收起。
剛開始接到這個新需求時,沒什麼思路。轉念一想
PopupWindow
有一個
setOutsideTouchable()
:
public class PopupWindow { /** * <p>Controls whether the pop-up will be informed of touch events outside * of its window. * * @param touchable true if the popup should receive outside * touch events, false otherwise */ public void setOutsideTouchable(boolean touchable) { mOutsideTouchable = touchable; } } 複製程式碼
該函式用於設定是否允許 window 邊界外的觸控事件傳遞給 window。跟蹤
mOutsideTouchable
變數應該就能找到更多線索:
public class PopupWindow { private int computeFlags(int curFlags) { curFlags &= ~( WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM | WindowManager.LayoutParams.FLAG_SPLIT_TOUCH); ... //'如果界外可觸控,則將FLAG_WATCH_OUTSIDE_TOUCH賦值給flag' if (mOutsideTouchable) { curFlags |= WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH; } ... } } 複製程式碼
繼續往上跟蹤
computeFlags()
呼叫的地方:
public class PopupWindow { protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) { final WindowManager.LayoutParams p = new WindowManager.LayoutParams(); p.gravity = computeGravity(); //'計算視窗布局引數flag屬性並賦值' p.flags = computeFlags(p.flags); p.type = mWindowLayoutType; p.token = token; ... } } 複製程式碼
而
createPopupLayoutParams()
會在視窗顯示的時候被呼叫:
public class PopupWindow { public void showAtLocation(IBinder token, int gravity, int x, int y) { if (isShowing() || mContentView == null) { return; } TransitionManager.endTransitions(mDecorView); detachFromAnchor(); mIsShowing = true; mIsDropdown = false; mGravity = gravity; //'構建視窗布局引數' final WindowManager.LayoutParams p = createPopupLayoutParams(token); preparePopup(p); p.x = x; p.y = y; invokePopup(p); } } 複製程式碼
想在原始碼中繼續搜尋,但到
FLAG_WATCH_OUTSIDE_TOUCH
,線索就斷了。現在只知道為了讓界外點選事件傳遞給 window,必須為佈局引數設定
FLAG_WATCH_OUTSIDE_TOUCH
。但事件響應邏輯應該寫在哪裡?
當呼叫
PopupWindow.setOutsideTouchable(true)
,在視窗界外點選後,視窗會消失。這必然是呼叫了
dismiss()
,沿著
dismiss()
的呼叫鏈往上找一定能找到界外點選事件的響應邏輯:
public class PopupWindow { //'視窗根檢視' private class PopupDecorView extends FrameLayout { //'視窗根檢視觸控事件' @Override public boolean onTouchEvent(MotionEvent event) { final int x = (int) event.getX(); final int y = (int) event.getY(); if ((event.getAction() == MotionEvent.ACTION_DOWN) && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) { dismiss(); return true; //'如果發生了界外觸控事件則解散視窗' } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) { dismiss(); return true; } else { return super.onTouchEvent(event); } } } } 複製程式碼
所以只需要在視窗根檢視的觸控事件回撥中捕獲
ACTION_OUTSIDE
即可:
object FloatWindow : View.OnTouchListener { //'界外觸控事件回撥' private var onTouchOutside: (() -> Unit)? = null //'設定是否響應界外點選事件' fun setOutsideTouchable(enable: Boolean, onTouchOutside: (() -> Unit)? = null) { windowInfo?.layoutParams?.let { layoutParams -> layoutParams.flags = layoutParams.flags or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH this.onTouchOutside = onTouchOutside } } override fun onTouch(v: View, event: MotionEvent): Boolean { //'界外觸控事件處理' if (event.action == MotionEvent.ACTION_OUTSIDE) { onTouchOutside?.invoke() return true } //'點選和拖拽事件處理' gestureDetector.onTouchEvent(event).takeIf { !it }?.also { //there is no ACTION_UP event in GestureDetector val action = event.action when (action) { MotionEvent.ACTION_UP -> onActionUp(event, screenWidth, windowInfo?.width ?: 0) else -> { } } } return true } } 複製程式碼
最後
star一下
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69952849/viewspace-2673516/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- FloatWindow 輕鬆實現安卓任意介面懸浮窗安卓
- Android 輕鬆實現 RecyclerView 懸浮條AndroidView
- Andorid 任意介面懸浮窗,實現懸浮窗如此簡單
- [轉]Android輕鬆實現RecyclerView懸浮條AndroidView
- 懸浮窗的一種實現 | Android懸浮窗Window應用Android
- Android 懸浮視窗的實現Android
- Android應用內懸浮窗的實現方案Android
- Android 懸浮窗Android
- 【轉載】使用WindowManage實現Android懸浮窗Android
- 怎麼簡單的繪製拓撲圖,用這個工具能讓你輕鬆實現
- 實用且簡單的Git教程,輕鬆搞定多人開發Git
- 下沉式通知的一種實現 | Android懸浮窗Window應用Android
- Android實現仿360手機衛士懸浮窗效果Android
- Android 懸浮窗 System Alert WindowAndroid
- Android懸浮窗的學習Android
- 輕鬆搞定動畫!17個有趣實用的CSS 3懸停效果教程動畫CSS
- Android懸浮框的實現Android
- Android 懸浮框實現方法Android
- Android實現流量統計和網速監控懸浮窗Android
- 怎樣在Excel中新增水印?學會這個方法可以輕鬆搞定Excel
- Android 攝像頭預覽懸浮窗Android
- Android仿微信文章懸浮窗效果Android
- Android懸浮窗--獲取記憶體Android記憶體
- 非侵入式無許可權應用內懸浮窗的實現
- Android 為應用增加可移動的懸浮視窗Android
- Android通過WindowManager實現懸浮框Android
- 如何在Android中實現懸浮ActivityAndroid
- 懸浮窗開發設計實踐
- 簡單幾步,教你在mac電腦上輕鬆啟用懸停文字!Mac
- GIS場景編輯如何實現?這款免費視覺化工具幫你輕鬆搞定視覺化
- Android 輔助許可權與懸浮窗Android
- 簡單介紹Vue實現滑鼠懸浮切換圖片srcVue
- 浮動應用程式視窗怎麼用?
- 如何編寫簡單的應用window視窗程式
- Android RecyclerView實現頭部懸浮吸頂效果AndroidView
- Android輕鬆搞定Dialog提示動畫效果Android動畫
- jquery 手寫一個簡單浮窗的反面教材jQuery
- 搞定JVM垃圾回收就是這麼簡單JVM