Android懸浮窗怎麼簡單實現?這樣用 kotlin編寫輕鬆搞定!

yilian發表於2020-01-15

本文以業務應用為出發點,從零開始抽象一個浮窗工具類,它用於在任意業務介面上展示懸浮窗。它可以同時管理多個浮窗,而且浮窗可以響應觸控事件,可拖拽,有貼邊動畫。

文中例項程式碼使用 kotlin 編寫,kotlin 系列教程可以點選  這裡

效果如下:

Android懸浮窗怎麼簡單實現?這樣用 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_DOWNACTION_MOVEACTION_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
    }
}
複製程式碼

最後

推一下我的GitHub交友地址:

star一下


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69952849/viewspace-2673516/,如需轉載,請註明出處,否則將追究法律責任。

相關文章