當你瀏覽公衆號時來了一條新消息,通知在屏幕頂部會以自頂向下動畫的形式入場(效果以下圖)。雖然上一篇中抽象的浮窗工具類已經能實現這個需求。但本文在此基礎上在封裝一些更加友好的 API 來實現下沉式通知。git
這是 Android Window 應用的第二篇,系列文章目錄以下:github
上一篇抽象的show()
接口經過指定x
、y
能精確地在屏幕任意位置顯示浮窗。但有時候業務需求是模糊的,好比「在屏幕右側中間顯示浮窗」。若是能新增一個 API,再預約義一些經常使用位置,這樣業務層就能夠不用關心窗口座標的計算。bash
屏幕的上下左右 4 個方向是經常使用位置,每一個位置又能夠有三種gravity
:起點、中點、終點。組合一下就有 12 個經常使用位置。app
固然能夠定義 12 個常量,它們的值從 0-11 。但當每一個位置新增一種 gravity,則要新增 4 個常量。將上下左右的方位和 gravity 分紅兩組能夠解決這個問題:dom
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()
函數:ide
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 個二元狀態。oop
狀態常量也是一個int
值,每一個狀態常量只和 32 位中的 1 位關聯,View
將其表示成 8 位的十六進制。(1 個 十六進制位 等價於 4 個二進制位,好比:)post
十六進制 | 二進制 |
---|---|
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
}
複製代碼
完整代碼可點擊上面連接。