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

當你瀏覽公衆號時來了一條新消息,通知在屏幕頂部會以自頂向下動畫的形式入場(效果以下圖)。雖然上一篇中抽象的浮窗工具類已經能實現這個需求。但本文在此基礎上在封裝一些更加友好的 API 來實現下沉式通知。git

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

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

預約義經常使用位置

上一篇抽象的show()接口經過指定xy能精確地在屏幕任意位置顯示浮窗。但有時候業務需求是模糊的,好比「在屏幕右側中間顯示浮窗」。若是能新增一個 API,再預約義一些經常使用位置,這樣業務層就能夠不用關心窗口座標的計算。bash

屏幕的上下左右 4 個方向是經常使用位置,每一個位置又能夠有三種gravity:起點、中點、終點。組合一下就有 12 個經常使用位置。app

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

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()函數:ide

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 個二元狀態。oop

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

十六進制 二進制
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
    }
複製代碼

talk is cheap, show me the code

完整代碼可點擊上面連接。

相關文章
相關標籤/搜索