当你浏览公众号时来了一条新消息,通知在屏幕顶部会以自顶向下动画的形式入场,而且它是跨界面的全局浮窗(效果如下图)。虽然上一篇中抽象的浮窗工具类已经能实现这个需求。但本文在此基础上再封装一些更加友好的 API 来实现下沉式通知。
这是 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 语法糖分析。
进一步重载提供默认动画
把构建浮窗入场动画的细节交由业务界面实现,这样虽然增加了灵活度,但也增加了业务代码的复杂度。如果能提供默认动画就更好了,重载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_OVERLAY
或TYPE_SYSTEM_ALERT
时,窗口就不隶属于某一个 Activity。这样就做到了全局展示。
talk is cheap, show me the code
完整代码可点击上面链接。