本文以業務應用為出發點,從零開始抽象一個浮窗工具類,它用於在任意業務介面上展示懸浮窗。它可以同時管理多個浮窗,而且浮窗可以響應觸控事件,可拖拽,有貼邊動畫。
文中例項程式碼使用 kotlin 編寫,kotlin 系列教程可以點選這裡
效果如下:
這是 Android Window 應用的第一篇,系列文章目錄如下:
顯示浮窗
原生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
}
}
複製程式碼
talk is cheap, show me the code
例項程式碼隱藏了不重要的細節,完整程式碼可點選上面連結。