- 悬浮窗的一种实现 | Android悬浮窗Window应用
- 下沉式通知的一种实现 | Android悬浮窗Window应用
预定义常用位置
上一篇抽象的show()
接口通过指定
x
、
y
能精确地在屏幕任意位置显示浮窗。
但有时候业务需求是模糊的,比如“在屏幕右侧中间显示浮窗”。
如果能新增一个 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)}
animSet
和
anim
是自定义 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。这样就做到了全局展示。
推荐↓↓↓
长
按
关
注
?【16个技术公众号】都在这里!
涵盖:程序员大咖、源码共读、程序员共读、数据结构与算法、黑客技术和网络安全、大数据科技、编程前端、Java、Python、Web编程开发、Android、iOS开发、Linux、数据库研发、幽默程序员等。