懸浮窗的一種實現 | Android懸浮窗Window應用

唐子玄發表於2019-12-19

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

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

效果如下:

float window

這是 Android Window 應用的第一篇,系列文章目錄如下:

  1. 懸浮窗的一種實現 | Android懸浮窗Window應用
  2. 下沉式通知的一種實現 | 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_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
    }
}
複製程式碼

talk is cheap, show me the code

例項程式碼隱藏了不重要的細節,完整程式碼可點選上面連結。

相關文章