android 悬浮窗_下沉式通知的一种实现 | Android悬浮窗Window应用

640?wx_fmt=jpeg

黑客技术 点击右侧关注,了解黑客的世界! 640?wx_fmt=jpeg

640?wx_fmt=jpeg

Java 开发进阶 点击右侧关注,掌握进阶之路! 640?wx_fmt=jpeg

640?wx_fmt=jpeg

Python开发 点击右侧关注,探讨技术话题! 640?wx_fmt=jpeg 作者丨唐子玄 来源丨掘金 https://juejin.im/post/5e0dd9145188253aae7d803d 当你浏览公众号时来了一条新消息,通知在屏幕顶部会以自顶向下动画的形式入场,而且它是跨界面的全局浮窗(效果如下图)。 虽然 上一篇 中抽象的浮窗工具类已经能实现这个需求。 但本文在此基础上在封装一些更加友好的 API 来实现下沉式通知。 640?wx_fmt=gif 这是 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 个二进制位,比如: ) 十六进制 二进制
10001
20010
30011
我原先习惯将一个状态位定义成一个 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 时要求浮窗持续展示。 只需要静态申请一个权限并修改窗口类型即可实现:

复制代码
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_OVERLAY或TYPE_SYSTEM_ALERT时,窗口就不隶属于某一个 Activity。这样就做到了全局展示。

 推荐↓↓↓ 

640?wx_fmt=jpeg

?16个技术公众号】都在这里!

涵盖:程序员大咖、源码共读、程序员共读、数据结构与算法、黑客技术和网络安全、大数据科技、编程前端、Java、Python、Web编程开发、Android、iOS开发、Linux、数据库研发、幽默程序员等。

640?wx_fmt=png万水千山总是情,点个 “ 在看” 行不行
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值