當(dāng)你瀏覽公眾號(hào)時(shí)來(lái)了一條新消息,通知在屏幕頂部會(huì)以自頂向下動(dòng)畫(huà)的形式入場(chǎng)勋陪,而且它是跨界面的全局浮窗(效果如下圖)。雖然上一篇中抽象的浮窗工具類(lèi)已經(jīng)能實(shí)現(xiàn)這個(gè)需求坯苹。但本文在此基礎(chǔ)上再封裝一些更加友好的 API 來(lái)實(shí)現(xiàn)下沉式通知逢防。
這是 Android Window 應(yīng)用的第二篇康聂,系列文章目錄如下:
預(yù)定義常用位置
上一篇抽象的show()
接口通過(guò)指定x
、y
能精確地在屏幕任意位置顯示浮窗胞四。但有時(shí)候業(yè)務(wù)需求是模糊的恬汁,比如“在屏幕右側(cè)中間顯示浮窗”。如果能新增一個(gè) API辜伟,再預(yù)定義一些常用位置氓侧,這樣業(yè)務(wù)層就可以不用關(guān)心窗口坐標(biāo)的計(jì)算。
屏幕的上下左右 4 個(gè)方向是常用位置导狡,每個(gè)位置又可以有三種gravity
:起點(diǎn)约巷、中點(diǎn)、終點(diǎn)旱捧。組合一下就有 12 個(gè)常用位置独郎。
當(dāng)然可以定義 12 個(gè)常量,它們的值從 0-11 枚赡。但當(dāng)每個(gè)位置新增一種 gravity氓癌,則要新增 4 個(gè)常量。將上下左右的方位和 gravity 分成兩組可以解決這個(gè)問(wèn)題:
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
}
重載一個(gè)帶有常用位置參數(shù)的show()
函數(shù):
object FloatWindow : View.OnTouchListener {
fun show(
context: Context,
tag: String,
windowInfo: WindowInfo? = windowInfoMap[tag],
//'新增參數(shù):位置'
position: Int,
//'新增參數(shù):gravity'
gravity: Int
) {
//'根據(jù)常用位置計(jì)算窗口左上角坐標(biāo)贫橙,省略了計(jì)算過(guò)程'
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 -> {...}
}
//'將計(jì)算出的坐標(biāo)傳入上一篇抽象的show函數(shù)'
show( context, tag, windowInfo, x, y, false)
}
}
沒(méi)毛病贪婉,但show()
函數(shù)新增了兩個(gè)參數(shù),而且這兩個(gè)參數(shù)得合起來(lái)才表達(dá)一個(gè)完整的語(yǔ)義:窗口的位置卢肃。
二進(jìn)制位管理多個(gè)狀態(tài)
有沒(méi)有什么辦法將兩個(gè)參數(shù)合并成一個(gè)參數(shù)疲迂?有!更好的解決方案就藏在View
的源碼里:
public class View {
/*
* '狀態(tài)常量'
* |-------|-------|-------|-------|
* 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;
//'當(dāng)前狀態(tài)'
public int mPrivateFlags;
}
View
將自身的所有狀態(tài)位存儲(chǔ)在一個(gè)int
類(lèi)型的mPrivateFlags
變量中莫湘。int
占用 4 個(gè)字節(jié)尤蒿,1 個(gè)字節(jié)包含 8 位二進(jìn)制,所以它可以存儲(chǔ) 32 個(gè)二元狀態(tài)幅垮。
狀態(tài)常量也是一個(gè)int
值腰池,每個(gè)狀態(tài)常量只和 32 位中的 1 位關(guān)聯(lián),View
將其表示成 8 位的十六進(jìn)制军洼。(1 個(gè) 十六進(jìn)制位 等價(jià)于 4 個(gè)二進(jìn)制位巩螃,比如:)
十六進(jìn)制 | 二進(jìn)制 |
---|---|
1 | 0001 |
2 | 0010 |
3 | 0011 |
我原先習(xí)慣將一個(gè)狀態(tài)位定義成一個(gè)int
值,現(xiàn)在看來(lái)匕争,可以將 32 個(gè)int
狀態(tài)值濃縮在一個(gè)int
值中。
新增狀態(tài)時(shí)爷耀,只需進(jìn)行位或操作:
mPrivateFlags |= PFLAG_DRAWN;
判斷當(dāng)前是否具有某種狀態(tài)時(shí)甘桑,只需位與操作:
public boolean hasFocus() {
return (mPrivateFlags & PFLAG_FOCUSED) != 0;
}
刪除狀態(tài)時(shí),只需取反加位與操作:
mPrivateFlags &= ~PFLAG_OPAQUE_BACKGROUND;
使用二進(jìn)制位來(lái)管理數(shù)量眾多的狀態(tài)時(shí)不僅節(jié)約了內(nèi)存,而且狀態(tài)的變更和判斷變得輕松跑杭,復(fù)合狀態(tài)的表達(dá)變得簡(jiǎn)單
雖然當(dāng)前業(yè)務(wù)場(chǎng)景中只包含一種狀態(tài)铆帽,即浮窗的常用位置,但它是一種復(fù)合狀態(tài)德谅,包含了位置和 gravity爹橱,使用二進(jìn)制位管理,可以簡(jiǎn)化代碼:
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()
的參數(shù)表就可以被簡(jiǎn)化:
object FloatWindow : View.OnTouchListener {
fun show(
context: Context,
tag: String,
//包含窗口寬高和坐標(biāo)的包裝類(lèi)
windowInfo: WindowInfo? = windowInfoMap[tag],
//'flag包含了位置和gravity信息'
flag: Int
) {
//'將 flag 解析成坐標(biāo)并顯示浮窗'
getShowPoint(flag, windowInfo, offset).let { show(context, tag, windowInfo, it.x, it.y, false) }
}
}
flag 的解析寫(xiě)在getShowPoint()
中:
object FloatWindow : View.OnTouchListener {
private fun getShowPoint(flag: Int, windowInfo: WindowInfo?): Point {
return when {
//'構(gòu)建頂部浮窗坐標(biāo)'
flag.and(FLAG_TOP) != 0 -> {
val y = -windowInfo?.height.value()
//'解析flag中的gravity部分'
val x = getValueByGravity(flag, screenWidth, windowInfo?.width.value())
Point(x, y)
}
//'構(gòu)建底部浮窗坐標(biāo)'
flag.and(FLAG_BOTTOM) != 0 -> {
val y = screenHeight
val x = getValueByGravity(flag, screenWidth, windowInfo?.width.value())
Point(x, y)
}
//'構(gòu)建左邊浮窗坐標(biāo)'
flag.and(FLAG_LEFT) != 0 -> {
val x = -windowInfo?.width.value()
val y = getValueByGravity(flag, screenHeight, windowInfo?.height.value())
Point(x, y)
}
//'構(gòu)建右邊浮窗坐標(biāo)'
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愧驱。
解析位置時(shí),為了使浮窗有移入屏幕的效果椭盏,遂將其初始位置都置于屏幕外且緊貼屏幕邊緣组砚。比如頂部浮窗的下邊緣貼住屏幕上邊緣,所以浮窗左上角 y = -浮窗高度
解析 gravity 邏輯寫(xiě)在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
表示對(duì)應(yīng)的浮窗寬(高)
移入動(dòng)畫(huà)
將浮窗初始位置置于屏幕之外且緊貼屏幕后糟红,只需要再設(shè)置一個(gè)位移動(dòng)畫(huà)即可實(shí)現(xiàn)移入屏幕的效果:
object FloatWindow : View.OnTouchListener {
fun show(
context: Context,
tag: String,
windowInfo: WindowInfo? = windowInfoMap[tag],
flag: Int,
//'窗口位移動(dòng)畫(huà)回調(diào)'
onAnimateWindow: ((WindowInfo?) -> Unit)?
) {
getShowPoint(flag, windowInfo).let { show(context, tag, windowInfo, it.x, it.y, false) }
//'在當(dāng)前消息隊(duì)列末尾執(zhí)行窗口位移動(dòng)畫(huà)'
windowInfo?.view?.post { onAnimateWindow?.invoke(windowInfo) }
}
}
這個(gè)重載show()
函數(shù)將動(dòng)畫(huà)的實(shí)現(xiàn)交給業(yè)務(wù)層,動(dòng)畫(huà)執(zhí)行的時(shí)間點(diǎn)被安排在消息隊(duì)列末尾乌叶,之所以這樣做是因?yàn)橐_保動(dòng)畫(huà)在窗口顯示之后執(zhí)行盆偿。
現(xiàn)在業(yè)務(wù)界面就可以像這樣顯示頂部下沉窗口:
var handler = Handler(Looper.getMainLooper())
val view = LayoutInflater.from(this).inflate(R.layout.gravity_vertical_window, null)
//'構(gòu)建窗口寬高參數(shù)'
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毫秒后反向播放動(dòng)畫(huà),即隱藏下沉式窗口'
handler.postDelayed({ anim.reverse() }, 1500)
}
animSet
和anim
是自定義 DSL准浴,用于簡(jiǎn)化構(gòu)建動(dòng)畫(huà)代碼陈肛,其運(yùn)用的 Kotlin 語(yǔ)法糖分析,可以點(diǎn)擊Kotlin進(jìn)階:動(dòng)畫(huà)代碼太丑兄裂,用DSL動(dòng)畫(huà)庫(kù)拯救句旱,像說(shuō)話一樣寫(xiě)代碼喲!晰奖。
進(jìn)一步重載提供默認(rèn)動(dòng)畫(huà)
把構(gòu)建浮窗入場(chǎng)動(dòng)畫(huà)的細(xì)節(jié)交由業(yè)務(wù)界面實(shí)現(xiàn)谈撒,這樣雖然增加了靈活度,但也增加了業(yè)務(wù)代碼的復(fù)雜度匾南。如果能提供默認(rèn)動(dòng)畫(huà)就更好了啃匿,重載show()
:
object FloatWindow : View.OnTouchListener {
//'提供默認(rèn)動(dòng)畫(huà)的浮窗顯示重載函數(shù)'
fun show(
context: Context,
tag: String,
windowInfo: WindowInfo? = windowInfoMap[tag],
flag: Int,
offset: Int = 0,
//'浮窗顯示和隱藏動(dòng)畫(huà)時(shí)長(zhǎng)'
duration: Long = 250L,
//'浮窗停留時(shí)長(zhǎng)'
stayTime: Long = 1500L
) {
getShowPoint(flag, windowInfo, offset).let { show(context, tag, windowInfo, it.x, it.y, false) }
windowInfo?.view?.post {
//'構(gòu)建浮窗出場(chǎng)動(dòng)畫(huà)'
getShowAnim(flag, windowInfo, duration)?.also { anim ->
anim.start()
//'延遲隱藏浮窗'
handler.postDelayed({
anim.reverse()
//'隱藏浮窗動(dòng)畫(huà)結(jié)束后,解散浮窗'
anim.onEnd = { dismiss(windowInfo) }
}, stayTime)
}
}
}
}
getShowAnim()
通過(guò)解析 flag 構(gòu)建對(duì)應(yīng)出場(chǎng)動(dòng)畫(huà):
object FloatWindow : View.OnTouchListener {
private fun getShowAnim(flag: Int, windowInfo: WindowInfo?, duration: Long): AnimSet? = when {
//'構(gòu)建自頂向下動(dòng)畫(huà)'
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) }
}
}
}
//'構(gòu)建自底向上動(dòng)畫(huà)'
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) }
}
}
}
//'構(gòu)建從左往右動(dòng)畫(huà)'
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) }
}
}
}
//'構(gòu)建從右往左動(dòng)畫(huà)'
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 個(gè)常用位置蛆楞,但浮窗出場(chǎng)動(dòng)畫(huà)只有 4 個(gè)方位溯乒,即自頂向下、自底向上豹爹、從左往右裆悄、從右往左。
上滑隱藏浮窗
若想實(shí)現(xiàn) “手指上滑隱藏不感興趣的通知”臂聋,只需在監(jiān)聽(tīng)到 fling 手勢(shì)時(shí)反向播放動(dòng)畫(huà):
object FloatWindow : View.OnTouchListener {
private var gestureDetector: GestureDetector = GestureDetector(context, GestureListener())
//'浮窗出入場(chǎng)動(dòng)畫(huà)'
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 {
//'反轉(zhuǎn)入場(chǎng)動(dòng)畫(huà)'
inAndOutAnim?.let { anim ->
anim.reverse()
anim.onEnd = { dismiss(windowInfo) }
return true
}
return false
}
}
}
inAndOutAnim
應(yīng)該在兩個(gè)重載show()
函數(shù)中被賦值光稼,遂修改show()
函數(shù)如下:
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 {
//'構(gòu)建默認(rèn)出入場(chǎng)動(dòng)畫(huà)時(shí)給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) }
//'業(yè)務(wù)界面構(gòu)建的出入場(chǎng)動(dòng)畫(huà)作為lambda的返回值并賦給inAndOutAnim'
windowInfo?.view?.post { inAndOutAnim = onAnimateWindow?.invoke(windowInfo) }
}
}
//'業(yè)務(wù)界面這樣顯示下沉式通知'
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)
//'將構(gòu)建的動(dòng)畫(huà)實(shí)例作為lambda值'
anim
}
全局浮窗
通知類(lèi)型浮窗和其他浮窗不同或南,它是全局的,當(dāng)切換 Activity 時(shí)要求浮窗持續(xù)展示艾君。只需要靜態(tài)申請(qǐng)一個(gè)權(quán)限并修改窗口類(lèi)型即可實(shí)現(xiàn):
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
在AndroidManifest.xml
中添加這個(gè)權(quán)限采够,然后修改show()
函數(shù),增加全局參數(shù):
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
) {
...
//'構(gòu)建全局浮窗布局參數(shù)'
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 {
//'為全局浮窗指定不一樣的窗口類(lèi)型'
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
}
}
}
當(dāng) Window 類(lèi)型設(shè)置為TYPE_APPLICATION_OVERLAY
或TYPE_SYSTEM_ALERT
時(shí)冰垄,窗口就不隸屬于某一個(gè) Activity蹬癌。這樣就做到了全局展示。
talk is cheap, show me the code
完整代碼可點(diǎn)擊上面鏈接虹茶。