當你瀏覽公眾號時來了一條新訊息,通知在螢幕頂部會以自頂向下動畫的形式入場,而且它是跨介面的全域性浮窗(效果如下圖)。雖然上一篇中抽象的浮窗工具類已經能實現這個需求。但本文在此基礎上再封裝一些更加友好的 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 語法糖分析,可以點選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 時要求浮窗持續展示。只需要靜態申請一個許可權並修改視窗型別即可實現:
<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
完整程式碼可點選上面連結。