Android懸浮窗的一種實現

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

文中實例代碼使用 kotlin 編寫,kotlin 系列教程能夠點擊這裏github

效果以下: bash

float window

顯示浮窗

原生ViewManager接口提供了向窗口添加並操縱View的方法:app

public interface ViewManager{
    //'向窗口添加視圖'
    public void addView(View view, ViewGroup.LayoutParams params);
    //'更新窗口中視圖'
    public void updateViewLayout(View view, ViewGroup.LayoutParams params);
    //'移除窗口中視圖'
    public void removeView(View view);
}
複製代碼

使用這個接口顯示窗口的模版代碼以下:dom

//'解析佈局文件爲視圖'
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就能輕鬆實現:ide

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
    }
}
複製代碼

浮窗自動貼邊

新的需求來了,拖拽浮窗鬆手後,須要自動貼邊。post

把貼邊理解成一個水平位移動畫。在鬆手時求出動畫起點和終點橫座標,利用動畫值不斷更新浮窗位置::

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

實例代碼隱藏了不重要的細節,完整代碼可點擊上面連接。

相關文章
相關標籤/搜索