下沉式通知的一種實現 | Android懸浮窗Window應用

唐子玄發表於2020-01-06

當你瀏覽公眾號時來了一條新訊息,通知在螢幕頂部會以自頂向下動畫的形式入場,而且它是跨介面的全域性浮窗(效果如下圖)。雖然上一篇中抽象的浮窗工具類已經能實現這個需求。但本文在此基礎上再封裝一些更加友好的 API 來實現下沉式通知。

跨介面浮窗

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

  1. 懸浮窗的一種實現 | Android懸浮窗Window應用
  2. 下沉式通知的一種實現 | Android懸浮窗Window應用

預定義常用位置

上一篇抽象的show()介面通過指定xy能精確地在螢幕任意位置顯示浮窗。但有時候業務需求是模糊的,比如“在螢幕右側中間顯示浮窗”。如果能新增一個 API,再預定義一些常用位置,這樣業務層就可以不用關心視窗座標的計算。

螢幕的上下左右 4 個方向是常用位置,每個位置又可以有三種gravity:起點、中點、終點。組合一下就有 12 個常用位置。

當然可以定義 12 個常量,它們的值從 0-11 。但當每個位置新增一種 gravity,則要新增 4 個常量。將上下左右的方位和 gravity 分成兩組可以解決這個問題:

object FloatWindow : View.OnTouchListener {
    //'方位常量組'
    const val POSIITION_TOP = 1
    const val POSITION_LEFT = 2
    const val POSITION_RIGHT = 3
    const val POSITION_BOTTOM = 4
    
    //'gravity常量組'
    const val GRAVITY_START = 100
    const val GRAVITY_MID = 101
    const val GRAVITY_END = 102
}
複製程式碼

過載一個帶有常用位置引數的show()函式:

object FloatWindow : View.OnTouchListener {
    fun show(
        context: Context,
        tag: String,
        windowInfo: WindowInfo? = windowInfoMap[tag],
        //'新增引數:位置'
        position: Int,
        //'新增引數:gravity'
        gravity: Int
    ) {
        //'根據常用位置計算視窗左上角座標,省略了計算過程'
        when (position){
            POSITION_TOP -> {
                when (gravity) -> {
                    GRAVITY_START -> {...}
                    GRAVITY_MID -> {...}
                    GRAVITY_END -> {...}
                    else -> {...}
                }
            }
            POSITION_LEFT -> {
                when (gravity) -> {
                    GRAVITY_START -> {...}
                    GRAVITY_MID -> {...}
                    GRAVITY_END -> {...}
                    else -> {...}
                }
            }
            POSITION_RIGHT -> {
                when (gravity) -> {
                    GRAVITY_START -> {...}
                    GRAVITY_MID -> {...}
                    GRAVITY_END -> {...}
                    else -> {...}
                }
            }
            POSITION_BOTTOM -> {
                when (gravity) -> {
                    GRAVITY_START -> {...}
                    GRAVITY_MID -> {...}
                    GRAVITY_END -> {...}
                    else -> {...}
                }
            }
            else -> {...}
        }
        //'將計算出的座標傳入上一篇抽象的show函式'
        show( context, tag, windowInfo, x, y, false)
    }
}
複製程式碼

沒毛病,但show()函式新增了兩個引數,而且這兩個引數得合起來才表達一個完整的語義:視窗的位置。

二進位制位管理多個狀態

有沒有什麼辦法將兩個引數合併成一個引數?有!更好的解決方案就藏在View的原始碼裡:

public class View {
    /*
     * '狀態常量'
     * |-------|-------|-------|-------|
     *                                 1 PFLAG_WANTS_FOCUS
     *                                1  PFLAG_FOCUSED
     *                               1   PFLAG_SELECTED
     *                              1    PFLAG_IS_ROOT_NAMESPACE
     *                             1     PFLAG_HAS_BOUNDS
     *                            1      PFLAG_DRAWN
     *                           1       PFLAG_DRAW_ANIMATION
     *                          1        PFLAG_SKIP_DRAW
     *                        1          PFLAG_REQUEST_TRANSPARENT_REGIONS
     *                       1           PFLAG_DRAWABLE_STATE_DIRTY
     *                      1            PFLAG_MEASURED_DIMENSION_SET
     *                     1             PFLAG_FORCE_LAYOUT
     *                    1              PFLAG_LAYOUT_REQUIRED
     *                   1               PFLAG_PRESSED
     *                  1                PFLAG_DRAWING_CACHE_VALID
     *                 1                 PFLAG_ANIMATION_STARTED
     *                1                  PFLAG_SAVE_STATE_CALLED
     *               1                   PFLAG_ALPHA_SET
     *              1                    PFLAG_SCROLL_CONTAINER
     *             1                     PFLAG_SCROLL_CONTAINER_ADDED
     *            1                      PFLAG_DIRTY
     *            1                      PFLAG_DIRTY_MASK
     *          1                        PFLAG_OPAQUE_BACKGROUND
     *         1                         PFLAG_OPAQUE_SCROLLBARS
     *         11                        PFLAG_OPAQUE_MASK
     *        1                          PFLAG_PREPRESSED
     *       1                           PFLAG_CANCEL_NEXT_UP_EVENT
     *      1                            PFLAG_AWAKEN_SCROLL_BARS_ON_ATTACH
     *     1                             PFLAG_HOVERED
     *    1                              PFLAG_NOTIFY_AUTOFILL_MANAGER_ON_CLICK
     *   1                               PFLAG_ACTIVATED
     *  1                                PFLAG_INVALIDATED
     * |-------|-------|-------|-------|
     */
    /** {@hide} */
    static final int PFLAG_WANTS_FOCUS                 = 0x00000001;
    /** {@hide} */
    static final int PFLAG_FOCUSED                     = 0x00000002;
    /** {@hide} */
    static final int PFLAG_SELECTED                    = 0x00000004;
    /** {@hide} */
    static final int PFLAG_IS_ROOT_NAMESPACE           = 0x00000008;
    //'當前狀態'
    public int mPrivateFlags;
}
複製程式碼

View將自身的所有狀態位儲存在一個int型別的mPrivateFlags變數中。int佔用 4 個位元組,1 個位元組包含 8 位二進位制,所以它可以儲存 32 個二元狀態。

狀態常量也是一個int值,每個狀態常量只和 32 位中的 1 位關聯,View將其表示成 8 位的十六進位制。(1 個 十六進位制位 等價於 4 個二進位制位,比如:)

十六進位制 二進位制
1 0001
2 0010
3 0011

我原先習慣將一個狀態位定義成一個int值,現在看來,可以將 32 個int狀態值濃縮在一個int值中。

新增狀態時,只需進行位或操作:

mPrivateFlags |= PFLAG_DRAWN;
複製程式碼

判斷當前是否具有某種狀態時,只需位與操作:

public boolean hasFocus() {
    return (mPrivateFlags & PFLAG_FOCUSED) != 0;
}
複製程式碼

刪除狀態時,只需取反加位與操作:

mPrivateFlags &= ~PFLAG_OPAQUE_BACKGROUND;
複製程式碼

使用二進位制位來管理數量眾多的狀態時不僅節約了記憶體,而且狀態的變更和判斷變得輕鬆,複合狀態的表達變得簡單

雖然當前業務場景中只包含一種狀態,即浮窗的常用位置,但它是一種複合狀態,包含了位置和 gravity,使用二進位制位管理,可以簡化程式碼:

object FloatWindow : View.OnTouchListener {
    //'使用0-3位表示gravity'
    const val FLAG_START = 0x00000001
    const val FLAG_MID = 0x00000002
    const val FLAG_END = 0x00000004
    //'使用第4-7位表示位置'
    const val FLAG_TOP = 0x00000010
    const val FLAG_LEFT = 0x00000020
    const val FLAG_RIGHT = 0x00000040
    const val FLAG_BOTTOM = 0x00000080
}
複製程式碼

這樣show()的參數列就可以被簡化:

object FloatWindow : View.OnTouchListener {
    fun show(
        context: Context,
        tag: String,
        //包含視窗寬高和座標的包裝類
        windowInfo: WindowInfo? = windowInfoMap[tag],
        //'flag包含了位置和gravity資訊'
        flag: Int
    ) {
        //'將 flag 解析成座標並顯示浮窗'
        getShowPoint(flag, windowInfo, offset).let { show(context, tag, windowInfo, it.x, it.y, false) }
    }
}
複製程式碼

flag 的解析寫在getShowPoint()中:

object FloatWindow : View.OnTouchListener {
    private fun getShowPoint(flag: Int, windowInfo: WindowInfo?): Point {
        return when {
            //'構建頂部浮窗座標'
            flag.and(FLAG_TOP) != 0 -> {
                val y = -windowInfo?.height.value()
                //'解析flag中的gravity部分'
                val x = getValueByGravity(flag, screenWidth, windowInfo?.width.value())
                Point(x, y)
            }
            //'構建底部浮窗座標'
            flag.and(FLAG_BOTTOM) != 0 -> {
                val y = screenHeight
                val x = getValueByGravity(flag, screenWidth, windowInfo?.width.value())
                Point(x, y)
            }
            //'構建左邊浮窗座標'
            flag.and(FLAG_LEFT) != 0 -> {
                val x = -windowInfo?.width.value()
                val y = getValueByGravity(flag, screenHeight, windowInfo?.height.value())
                Point(x, y)
            }
            //'構建右邊浮窗座標'
            flag.and(FLAG_RIGHT) != 0 -> {
                val x = screenWidth
                val y = getValueByGravity(flag, screenHeight, windowInfo?.height.value())
                Point(x, y)
            }
            else -> Point(0, 0)
        }
    }
}
複製程式碼

解析 flag 分兩步,先解析位置再解析 gravity。

解析位置時,為了使浮窗有移入螢幕的效果,遂將其初始位置都置於螢幕外且緊貼螢幕邊緣。比如頂部浮窗的下邊緣貼住螢幕上邊緣,所以浮窗左上角 y = -浮窗高度

解析 gravity 邏輯寫在getValueByGravity()中:

private fun getValueByGravity(flag: Int, total: Int, actual: Int): Int = when {
    flag.and(FLAG_START) != 0 -> 0
    flag.and(FLAG_MID) != 0 -> (total - actual) / 2
    flag.and(FLAG_END) != 0 -> (total - actual)
    else -> 0
}
複製程式碼

其中total表示邊螢幕寬(高),actual表示對應的浮窗寬(高)

移入動畫

將浮窗初始位置置於螢幕之外且緊貼螢幕後,只需要再設定一個位移動畫即可實現移入螢幕的效果:

object FloatWindow : View.OnTouchListener {
    fun show(
        context: Context,
        tag: String,
        windowInfo: WindowInfo? = windowInfoMap[tag],
        flag: Int,
        //'視窗位移動畫回撥'
        onAnimateWindow: ((WindowInfo?) -> Unit)?
    ) {
        getShowPoint(flag, windowInfo).let { show(context, tag, windowInfo, it.x, it.y, false) }
        //'在當前訊息佇列末尾執行視窗位移動畫'
        windowInfo?.view?.post { onAnimateWindow?.invoke(windowInfo) }
    }
}
複製程式碼

這個過載show()函式將動畫的實現交給業務層,動畫執行的時間點被安排在訊息佇列末尾,之所以這樣做是因為要確保動畫在視窗顯示之後執行。

現在業務介面就可以像這樣顯示頂部下沉視窗:

var handler = Handler(Looper.getMainLooper())
val view = LayoutInflater.from(this).inflate(R.layout.gravity_vertical_window, null)
//'構建視窗寬高引數'
val windowInfo = FloatWindow.WindowInfo(view).apply {
    width = DimensionUtil.dp2px(300.0)
    height = DimensionUtil.dp2px(80.0)
}

//'在螢幕頂部的正中位置顯示下沉式視窗'
FloatWindow.show(this, "top", windowInfo, FLAG_TOP or FLAG_MID) { info ->
    val anim = animSet {
        anim {
            values = intArrayOf(info.layoutParams?.y ?: 0, 0)
            interpolator = LinearOutSlowInInterpolator()
            duration = 250L
            action = { value -> FloatWindow.updateWindowView(y = value as Int) }
        }
        start()
    }
    //'1500毫秒後反向播放動畫,即隱藏下沉式視窗'
    handler.postDelayed({ anim.reverse() }, 1500)
}
複製程式碼

animSetanim是自定義 DSL,用於簡化構建動畫程式碼,其運用的 Kotlin 語法糖分析,可以點選Kotlin進階:動畫程式碼太醜,用DSL動畫庫拯救,像說話一樣寫程式碼喲!

進一步過載提供預設動畫

把構建浮窗入場動畫的細節交由業務介面實現,這樣雖然增加了靈活度,但也增加了業務程式碼的複雜度。如果能提供預設動畫就更好了,過載show()

object FloatWindow : View.OnTouchListener {
    //'提供預設動畫的浮窗顯示過載函式'
    fun show(
        context: Context,
        tag: String,
        windowInfo: WindowInfo? = windowInfoMap[tag],
        flag: Int,
        offset: Int = 0,
        //'浮窗顯示和隱藏動畫時長'
        duration: Long = 250L,
        //'浮窗停留時長'
        stayTime: Long = 1500L
    ) {
        getShowPoint(flag, windowInfo, offset).let { show(context, tag, windowInfo, it.x, it.y, false) }
        windowInfo?.view?.post {
            //'構建浮窗出場動畫'
            getShowAnim(flag, windowInfo, duration)?.also { anim ->
                anim.start()
                //'延遲隱藏浮窗'
                handler.postDelayed({
                    anim.reverse()
                    //'隱藏浮窗動畫結束後,解散浮窗'
                    anim.onEnd = { dismiss(windowInfo) }
                }, stayTime)
            }
        }
    }
}
複製程式碼

getShowAnim()通過解析 flag 構建對應出場動畫:

object FloatWindow : View.OnTouchListener {
    private fun getShowAnim(flag: Int, windowInfo: WindowInfo?, duration: Long): AnimSet? = when {
        //'構建自頂向下動畫'
        flag.and(FLAG_TOP) != 0 -> {
            animSet {
                anim {
                    values = intArrayOf(windowInfo?.layoutParams?.y.value(), 0)
                    this.duration = duration
                    interpolator = LinearOutSlowInInterpolator()
                    action = { value -> updateWindowView(y = value as Int) }
                }
            }
        }
        //'構建自底向上動畫'
        flag.and(FLAG_BOTTOM) != 0 -> {
            animSet {
                anim {
                    values = intArrayOf(windowInfo?.layoutParams?.y.value(), windowInfo?.layoutParams?.y.value() - windowInfo?.height.value())
                    this.duration = duration
                    interpolator = LinearOutSlowInInterpolator()
                    action = { value -> updateWindowView(y = value as Int) }
                }
            }
        }
        //'構建從左往右動畫'
        flag.and(FLAG_LEFT) != 0 -> {
            animSet {
                anim {
                    values = intArrayOf(windowInfo?.layoutParams?.x.value(), 0)
                    this.duration = duration
                    interpolator = LinearOutSlowInInterpolator()
                    action = { value -> updateWindowView(x = value as Int) }
                }
            }
        }
        //'構建從右往左動畫'
        flag.and(FLAG_RIGHT) != 0 -> {
            animSet {
                anim {
                    values = intArrayOf(windowInfo?.layoutParams?.x.value(), windowInfo?.layoutParams?.x.value() - windowInfo?.layoutParams?.width.value())
                    this.duration = duration
                    interpolator = LinearOutSlowInInterpolator()
                    action = { value -> updateWindowView(x = value as Int) }
                }
            }
        }
        else -> null
    }
}
複製程式碼

雖然有 12 個常用位置,但浮窗出場動畫只有 4 個方位,即自頂向下、自底向上、從左往右、從右往左。

上滑隱藏浮窗

若想實現 “手指上滑隱藏不感興趣的通知”,只需在監聽到 fling 手勢時反向播放動畫:

object FloatWindow : View.OnTouchListener {
    private var gestureDetector: GestureDetector = GestureDetector(context, GestureListener())
    //'浮窗出入場動畫'
    private var inAndOutAnim: Anim? = null
    
    override fun onTouch(v: View, event: MotionEvent): Boolean {
        //'將觸控事件傳遞給GestureDetector解析'
        gestureDetector.onTouchEvent(event)
        return true
    }
    
    private class GestureListener : GestureDetector.OnGestureListener {
        ...
        //'GestureDetector解析觸控事件成fling事件'
        override fun onFling(
            e1: MotionEvent,
            e2: MotionEvent,
            velocityX: Float,
            velocityY: Float
        ): Boolean {
            //'反轉入場動畫'
            inAndOutAnim?.let { anim ->
                anim.reverse()
                anim.onEnd = { dismiss(windowInfo) }
                return true
            }
            return false
        }
    }
}
複製程式碼

inAndOutAnim應該在兩個過載show()函式中被賦值,遂修改show()函式如下:

object FloatWindow : View.OnTouchListener {
    fun show(
        context: Context,
        tag: String,
        windowInfo: WindowInfo? = windowInfoMap[tag],
        flag: Int,
        offset: Int = 0,
        duration: Long = 250L,
        stayTime: Long = 1500L
    ) {
        getShowPoint(flag, windowInfo, offset).let { show(context, tag, windowInfo, it.x, it.y, false) }
        windowInfo?.view?.post {
            //'構建預設出入場動畫時給inAndOutAnim賦值'
            inAndOutAnim = getShowAnim(flag, windowInfo, duration)?.also { anim ->
                anim.start()
                handler.postDelayed({
                    anim.reverse()
                    anim.onEnd = { dismiss(windowInfo) }
                }, stayTime)
            }
        }
    }
    
    fun show(
        context: Context,
        tag: String,
        windowInfo: WindowInfo? = windowInfoMap[tag],
        flag: Int,
        offset: Int = 0,
        //'修改lambda返回值為Anim'
        onAnimateWindow: ((WindowInfo) -> Anim)?
    ) {
        getShowPoint(flag, windowInfo, offset).let { show(context, tag, windowInfo, it.x, it.y, false) }
        //'業務介面構建的出入場動畫作為lambda的返回值並賦給inAndOutAnim'
        windowInfo?.view?.post { inAndOutAnim = onAnimateWindow?.invoke(windowInfo) }
    }
}

//'業務介面這樣顯示下沉式通知'
val view = LayoutInflater.from(this).inflate(R.layout.gravity_vertical_window, null)
    val windowInfo = FloatWindow.WindowInfo(view).apply {
        width = DimensionUtil.dp2px(300.0)
        height = DimensionUtil.dp2px(80.0)
    }

    FloatWindow.show(this, "top", windowInfo, FLAG_TOP or FLAG_MID) { info ->
        val anim = animSet {
            anim {
                values = intArrayOf(info.layoutParams?.y ?: 0, 0)
                interpolator = LinearOutSlowInInterpolator()
                duration = 250L
                action = { value -> FloatWindow.updateWindowView(y = value as Int) }
            }
            start()
        }
        handler.postDelayed({ anim.reverse() }, 1500)
        //'將構建的動畫例項作為lambda值'
        anim
    }
複製程式碼

全域性浮窗

通知型別浮窗和其他浮窗不同,它是全域性的,當切換 Activity 時要求浮窗持續展示。只需要靜態申請一個許可權並修改視窗型別即可實現:

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
複製程式碼

AndroidManifest.xml中新增這個許可權,然後修改show()函式,增加全域性引數:

object FloatWindow : View.OnTouchListener {
    fun show(
        context: Context,
        tag: String,
        windowInfo: WindowInfo? = windowInfoMap[tag],
        x: Int = windowInfo?.layoutParams?.x.value(),
        y: Int = windowInfo?.layoutParams?.y.value(),
        dragEnable: Boolean = false,
        //'是否是全域性浮窗'
        overall: Boolean = false
    ) {
        ...
        //'構建全域性浮窗佈局引數'
        windowInfo.layoutParams = createLayoutParam(x, y, overall)
        if (!windowInfo.hasParent().value()) {
            val windowManager = this.context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
            prepareScreenDimension(windowManager)
            windowManager?.addView(windowInfo.view, windowInfo.layoutParams)
            updateWindowViewSize()
            onWindowShow?.invoke()
        }
    }
    
    private fun createLayoutParam(x: Int, y: Int, overall: Boolean): WindowManager.LayoutParams {
        if (context == null) {
            return WindowManager.LayoutParams()
        }

        return WindowManager.LayoutParams().apply {
            //'為全域性浮窗指定不一樣的視窗型別'
            type = if (overall) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                } else {
                    WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
                }
            } else {
                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
            dimAmount = 0f
            this.gravity = Gravity.START or Gravity.TOP
            width = windowInfo?.width.value()
            height = windowInfo?.height.value()
            this.x = x
            this.y = y
        }
    }
}
複製程式碼

當 Window 型別設定為TYPE_APPLICATION_OVERLAYTYPE_SYSTEM_ALERT時,視窗就不隸屬於某一個 Activity。這樣就做到了全域性展示。

talk is cheap, show me the code

完整程式碼可點選上面連結。

相關文章