完整叫法應(yīng)該是圓形揭露動畫,下文簡稱揭露動畫,因為 Android 系統(tǒng)中只提供了這一種圓形的(Circular)揭露動畫!
所謂揭露動畫,就是一種用于 View 之間,甚至界面之間的特殊過渡動畫效果。
AndroidPlatform 的 android.view 包下有個 ViewAnimationUtils 類,這是使用系統(tǒng)所提供揭露動畫的唯一入口,其對外暴露的唯一接口如下:
| Public methods | |
|---|---|
| static Animator! |
createCircularReveal(view: View!, centerX: Int, centerY: Int, startRadius: Float, endRadius: Float)Returns an Animator which can animate a clipping circle. |
通過其靜態(tài)的 createCircularReveal 方法來構(gòu)造一個動畫(Animator)對象,具體其實是個 RevealAnimator 類對象,進而可以實現(xiàn)一種炫酷(到底炫不炫酷就很主觀了)的動畫效果!
靠動圖來闡明揭露之意再合適不過,為此我寫了個小 demo,運行效果如下:
App 的揭露效果:
Activity 的揭露效果:
普通 View 的揭露效果:
幾圖勝千言!以上就是所謂的揭露動畫。Demo 源碼(Kotlin)我已放至 Github,源碼在此,下面我們好好聊下這種過渡動畫的具體實現(xiàn)。
墻裂建議結(jié)合 Demo 閱讀本文,另外 Demo 中的代碼注釋十分詳細,讀者可以試試如果僅根據(jù) Demo 中的源碼注釋就能理解上面效果背后的所有原理……下面的正文我還是建議你讀一下!
正文
基礎(chǔ) API
先來聊聊揭露動畫 Api 的基礎(chǔ)用法。
上面說到揭露動畫對外暴露的唯一使用接口是 ViewAnimationUtils 類的一個靜態(tài)方法 createCircularReveal??创朔椒ǖ耐暾灻形鍌€參數(shù),用于在方法內(nèi)部構(gòu)造一個揭露動畫以返回給用例:
createCircularReveal(View view, int centerX, int centerY, float startRadius, float endRadius)
第一個參數(shù)是個 View,揭露動畫的應(yīng)用對象必須是一個 View,這點不難理解。
第二個參數(shù)是圓形揭露效果的圓心 X 軸坐標,同理第三個參數(shù)是 Y 軸坐標。
第三個參數(shù)是圓形揭露效果的開始半徑,同理第四個參數(shù)是圓形揭露效果的終止半徑,開始半徑傳 0,終止半徑傳 View 的寬度或高度就是個典型的從無到有的揭露(顯示)過程,反之,開始半徑傳 View 的寬度或高度,終止半徑傳 0 就是個從有到無的反揭露(隱藏)過程。
拿到此方法返回的 Animator 對象我們就可以隨時控制 View 進行揭露動畫了。
是不是很簡單?
View 級別的揭露動畫
我們先來看看最簡單的普通 View 的揭露動畫效果(上面第三張圖)其具體代碼是怎樣的,
/* Demo 的關(guān)鍵代碼文件結(jié)構(gòu)
appreveal
│ MainActivity.kt
│ SecondActivity.kt //普通 View 的揭露效果見 Demo 里的這個文件中的代碼
│
├─base
│ BaseActivity.kt //Activity 和 App 層面的揭露動畫效果主要見 Demo 里此文件中的代碼
│
├─ext
│ ActicityExtension.kt
│
└─util
StatusBarUtil.kt
*/
普通 View 的揭露動畫見 Demo 里的 SecondActivity,其布局如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
tools:context=".SecondActivity">
<!--我們要對這個藍色背景 View 做揭露和反揭露動畫-->
<View
android:id="@+id/viewBg"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:background="@android:color/holo_blue_bright"
android:visibility="visible"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintVertical_bias="0.1"
android:text="@string/app_second"
android:textSize="30sp"/>
<!--點擊這個按鈕開始揭露、反揭露動畫-->
<Button
android:id="@+id/btnReveal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_reveal_r"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
SecondActivity 中設(shè)置點擊中間的按鈕開始 id 為 viewBg 界面控件的揭露動畫,關(guān)鍵代碼如下:
//中間按鈕的點擊事件
btnReveal.setOnClickListener { view ->
//系統(tǒng)提供的揭露動畫需 5.0 及以上的 sdk 版本
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return@setOnClickListener
}
//動畫開始半徑和結(jié)束半徑,兩者相對關(guān)系可用于控制是揭露還是反揭露,也即是從無到有還是從有到無
val startRadius:Float
val endRadius:Float
if (viewBg.visibility == View.VISIBLE){
//從有到無,即反揭露
startRadius = viewBg.height.toFloat()
endRadius = 0f
}else{
//從無到有,即揭露效果
startRadius = 0f
endRadius = viewBg.height.toFloat()
}
val location = IntArray(2)
view.getLocationInWindow(location)
//關(guān)鍵代碼,構(gòu)建一個揭露動畫對象,注意圓形揭露動畫的圓心以及開始半徑和結(jié)束半徑是如何計算出來的,應(yīng)該很好理解,這里不做過多解釋
val animReveal = ViewAnimationUtils.createCircularReveal(viewBg,
location[0] + view.width/2,
location[1] + view.height/2,
startRadius,
endRadius
)
//構(gòu)建好了揭露動畫對象,開始設(shè)置動畫的一些屬性和相關(guān)監(jiān)聽
animReveal.duration = 400
animReveal.interpolator = LinearInterpolator()
animReveal.addListener(onStart = {
viewBg.visibility = View.VISIBLE
},onEnd = {
if (startRadius != 0f){
viewBg.visibility = View.INVISIBLE
btnReveal.setText(R.string.app_reveal)
}else{
viewBg.visibility = View.VISIBLE
btnReveal.setText(R.string.app_reveal_r)
}
})
animReveal.start()
}
代碼真機運行的具體界面效果如下:
基本使用如此,為界面中的某個 View 應(yīng)用揭露動畫效果還是很簡單的,下面我們看看如何為應(yīng)用內(nèi) Activity 間的切換(上面Activity 的揭露效果一圖)應(yīng)用我們炫酷(主觀上的)的揭露動畫效果。
Activity 級別的揭露動畫
首先我們會遇到兩個問題:
- 揭露動畫用于 Activity 切換時,我們該把揭露動畫應(yīng)用于哪個 View(揭露動畫的應(yīng)用對象必須是一個 View)?
- 何時開始執(zhí)行揭露動畫?
根據(jù)我們得 Demo,一一作答。
揭露動畫用于 Activity 切換時,最合適的對象肯定是此 Activity 相關(guān) Window 的根視圖,真正的根視圖,沒錯正是此 Activity 的 Window 的 DecorView(DecorView 不是很了解?相關(guān)知識參考這篇博文)。
至于揭露動畫的開始時機,太早或太晚都不好。首先不能太早,如果當前 View 還未 Attach 到 Window 上就對其應(yīng)用揭露動畫會拋出異常,其次不能太晚,不然會嚴重影響動畫的視覺效果。
經(jīng)作者實踐,這個最好的揭露動畫開始時機在視圖的可見性剛變?yōu)閷τ脩艨梢姇r最佳!我們通過為 View 的 ViewTreeObserver 設(shè)置一個 OnGlobalLayoutListener 回調(diào)可完美監(jiān)聽到這個最佳時機~
我把相關(guān)實現(xiàn)代碼全都放在了 Demo 的 BaseActivity 類里,用例 Activity 只要繼承 BaseActivity 即可在打開時應(yīng)用揭露動畫效果。這里注意為了動畫的連貫性我們需要把 Activity 揭露動畫開始的圓心坐標從它的上個 Activity 里通過 Intent 傳遞過來,這點并不難實現(xiàn)。關(guān)鍵代碼如下:
//將 Activity 的揭露效果寫在 Base 類中,需要揭露動畫效果時繼承
abstract class BaseActivity : AppCompatActivity(){
companion object {
//手動往 intent 里傳入上個界面的點擊位置坐標
val CLICK_X = "CLICK_X"
val CLICK_Y = "CLICK_Y"
}
private var onGlobalLayout : ViewTreeObserver.OnGlobalLayoutListener? = null
//揭露(進入)動畫
var mAnimReveal : Animator? = null
//反揭露(退出)動畫
var mAnimRevealR : Animator? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
circularReveal(intent)
}
//Activity 揭露(進入)動畫,進入時使用
private fun circularReveal(intent: Intent?){
//系統(tǒng)提供的揭露動畫需 5.0 及以上的 sdk 版本,當我們獲取不到上個界面的點擊區(qū)域時就不展示揭露動畫,因為此時沒有合適的錨點
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ||
(intent?.sourceBounds == null && intent?.hasExtra(CLICK_X)?.not()?:true)) return
val rect = intent?.sourceBounds
val v = window.decorView
v.visibility = View.INVISIBLE
@SuppressWarnings
onGlobalLayout = object : ViewTreeObserver.OnGlobalLayoutListener{
override fun onGlobalLayout() {//此時既是開始揭露動畫的最佳時機
mAnimReveal?.removeAllListeners()
mAnimReveal?.cancel()
mAnimReveal = ViewAnimationUtils.createCircularReveal(v,
rect?.centerX()?:intent?.getIntExtra(CLICK_X, 0)?:0,
rect?.centerY()?:intent?.getIntExtra(CLICK_Y, 0)?:0,
0f,
v.height.toFloat()
)
mAnimReveal?.duration = 400
mAnimReveal?.interpolator = LinearInterpolator()
mAnimReveal?.addListener(onEnd = {
onGlobalLayout?.let {
//我們需要在揭露動畫進行完后及時移除回調(diào)
v?.viewTreeObserver?.removeOnGlobalLayoutListener(it)
}
})
mAnimReveal?.start()
}
}
//視圖可見性發(fā)生變化時的回調(diào),回調(diào)里正是開始揭露動畫的最佳時機
v.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayout)
}
//Activtiy 反揭露(退出)動畫,即退出時的過渡動畫,
//這么起名可能不恰當,其實還是同樣的動畫,
//只不過揭露的起始和終結(jié)半徑跟上面相比反過來了
private fun circularRevealReverse(intent: Intent?){
//系統(tǒng)提供的揭露動畫需 5.0 及以上的 sdk 版本,當我們獲取不到上個界面的點擊區(qū)域時就不展示揭露動畫,因為此時沒有合適的錨點
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ||
(intent?.sourceBounds == null && intent?.hasExtra(CLICK_X)?.not()?:true)) {
super.onBackPressed()
return
}
val rect = intent?.sourceBounds
val v = window.decorView
mAnimRevealR?.removeAllListeners()
mAnimRevealR?.cancel()
mAnimRevealR = ViewAnimationUtils.createCircularReveal(v,
rect?.centerX()?:intent?.getIntExtra(CLICK_X, 0)?:0,
rect?.centerY()?:intent?.getIntExtra(CLICK_Y, 0)?:0,
v.height.toFloat(),
0f
)
mAnimRevealR?.duration = 400
mAnimRevealR?.interpolator = LinearInterpolator()
mAnimRevealR?.addListener(onEnd = {
v.visibility = View.GONE
super.onBackPressed()
})
mAnimRevealR?.start()
}
//回退時應(yīng)用反揭露動畫
override fun onBackPressed() {
circularRevealReverse(intent)
}
//省略的其他代碼,見 Demo 里的 BaseActivity
...
}
MainActivity 里打開 SecondActivity(繼承了 BaseActivity) 的代碼:
btnNext.setOnClickListener {view ->
val intent = Intent(this, SecondActivity::class.java)
val location = IntArray(2)
view.getLocationInWindow(location)
//把點擊按鈕的中心位置坐標傳過去作為 SecondActivity 的揭露動畫圓心
intent.putExtra(CLICK_X, location[0] + view.width/2)
intent.putExtra(CLICK_Y, location[1] + view.height/2)
startActivity(intent)
}
我們重點看下 BaseActivity 的 circularReveal 方法,此方法在 onCreate 方法里被調(diào)用了一次,它的功能就是給整個 Activity 加上合適的揭露動畫效果,即在 DecorView 剛對用戶可見時開始構(gòu)建并執(zhí)行揭露動畫。
方法里的判斷有個條件:intent?.sourceBounds == null,這里的 intent?.sourceBounds 是什么東西?其實在當前應(yīng)用內(nèi),為 Activity 間切換時應(yīng)用揭露動畫效果完全可以不判斷這個條件,此代碼是寫給文章最后才會聊到的整個 App 的揭露效果(上面 App 的揭露效果那張圖)的。Intent 類里面有個方法簽名如下:
| open Rect? |
getSourceBounds()Get the bounds of the sender of this intent, in screen coordinates. |
|---|---|
此方法返回一個 Rect ,里面存儲著桌面(Launcher)里本應(yīng)用的圖標的位置信息,后面會詳細說到,且先略過不談。
onBackPressed 方法中調(diào)用了執(zhí)行反揭露動畫的方法 circularRevealReverse。
其他代碼經(jīng)過上面的鋪墊結(jié)合注釋應(yīng)該很好理解,最終運行到真機上動畫效果如下:
還有很重要的一點,因為 Activity 的 Window 會自帶有背景色(一般為黑色),所以我們需要對 Activity 的主題做些定制,以保證揭露動畫的效果:
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<!--背景透明且屏蔽系統(tǒng)默認的 Activity 過渡動畫-->
<style name="trans_no_anim" parent="AppTheme">
<!--屏蔽系統(tǒng)默認的 Activity 過渡動畫-->
<item name="android:windowAnimationStyle">@null</item>
<!--單純實現(xiàn) Activity 窗口背景透明-->
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
對 Activity 使用揭露動畫必須在清單文件中把其主題設(shè)置成 trans_no_anim。
App 級別的揭露動畫
最后要聊到的揭露動畫應(yīng)用場景比較有意思。
我們能不能給整個 App 加上一個完美的揭露動畫效果呢?
此處所謂完美的揭露動畫效果,即我們每次從 Launcher(Android 系統(tǒng)的 Launcher,即系統(tǒng)桌面,按 Home 鍵會回到的那個地方,也叫啟動器。注意 Launcher 在安卓系統(tǒng)中是個單獨的 APP,從桌面點擊應(yīng)用圖標啟動應(yīng)用的時候 Launcher 會把圖標的位置信息通過 intent 一路傳到應(yīng)用的 Launcher Activity,相關(guān)知識還請讀者自行 Google) 里點擊我們的應(yīng)用圖標,不管應(yīng)用是不是初次啟動,不管停留在哪個界面,都能通過揭露動畫打開或回到我們應(yīng)用,在我們的應(yīng)用中,通過回退按鈕(back 鍵)回到 Launcher 時都能通過反揭露動畫收起我們應(yīng)用(通過 Home 鍵回到 Launcher 時不應(yīng)用反揭露動畫,因為全面屏手勢正在 Android 系統(tǒng)中普及,其會令反揭露動畫效果不佳,此時用系統(tǒng)的默認動畫最好)。已知揭露動畫必須有個圓心,此處這個圓心必然應(yīng)該是 Launcher 中我們應(yīng)用圖標的中心位置的坐標。前文有提到,這個坐標的相關(guān)信息可通過 Launcher Activity 的 Intent 的 getSourceBounds() 方法獲得(非 Launcher Activity 通過此方法獲取到的只是個 null)。這里會有個問題,即 Launcher 中的圖標,其位置可是會隨時變化的(由用戶手動拖動)!完美的效果必然要能適應(yīng)坐標隨時會變化的啟動圖標。大概效果會是這樣:
答案是很遺憾,經(jīng)作者本人實踐,通過 Android 提供的現(xiàn)有 API 無法實現(xiàn)這種給整個 App 加上一個完美揭露動畫效果的需求!反正作者本人的嘗試以失敗而終!
從上面的動圖可以看出,我?guī)缀蹙鸵昝缹崿F(xiàn)這種效果了。問題就出在 Launcher 中的圖標,其位置可能會隨時變化(由用戶手動拖動),而我找不到及時更新 Launcher 圖標位置信息的方法!
先來看看光上圖所示效果是如何實現(xiàn)的。
還記得上面貼的比較重要的 BaseActivity 的代碼嗎,上面沒貼全,其下面還有代碼(注意結(jié)合上面貼過的代碼看):
//將 Activity 的揭露效果寫在 Base 類中,需要揭露動畫效果時繼承
abstract class BaseActivity : AppCompatActivity(){
//省略上文已貼過的代碼
...
...
override fun onDestroy() {
//及時釋放資源以保證代碼健壯性
mAnimReveal?.removeAllListeners()
mAnimReveal?.cancel()
mAnimRevealR?.removeAllListeners()
mAnimRevealR?.cancel()
//及時釋放資源以保證代碼健壯性
onGlobalLayout?.let {
window.decorView.viewTreeObserver?.removeOnGlobalLayoutListener(it)
}
super.onDestroy()
}
//這個方法很重要,如果我們應(yīng)用的啟動圖標在桌面上的位置有變化,可在此收到新的位置信息,然而經(jīng)作者本人實踐作用十分有限
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
circularReveal(intent)
//更新intent
this.intent = intent
}
}
注意 onNewIntent 方法,我們就是在這里更新本應(yīng)用的啟動圖標在 Launcher 中的位置信息的。
如何更新的呢?
已知點擊桌面上的圖標初次啟動應(yīng)用時,圖標的位置信息會通過 intent 傳遞給應(yīng)用的 Launcher Activity,應(yīng)用如果已經(jīng)啟動,只是退居后臺,此時再點擊啟動圖標回到應(yīng)用,會調(diào)用 onNewIntent 方法嗎?
一般不會,然而當從清單文件中把 Launcher Activity 的啟動模式設(shè)置成 standard 外的任意一種時,只要 Launcher Activity 在棧頂,從桌面點擊啟動圖標就會回調(diào)其 onNewIntent 方法,這個方法傳入的 intent 參數(shù)中保存著應(yīng)用啟動圖標在 Launcher 中的最新坐標信息!
比如我們把 MainActivity 的啟動模式設(shè)置成 singleTop:
<activity
android:name=".MainActivity"
android:launchMode="singleTop"> <!-- 只要 Activity 的 lanuchMode 不是 standard 的,當此 Activity 處于自己所在任務(wù)棧的棧頂時,每次點擊桌面中本應(yīng)用的啟動圖標時都會觸發(fā)此 Activity 的 onNewIntent 方法 -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
即可實現(xiàn)上圖所示的效果。
注意上文所言的限制條件,Launcher Activity 必須在棧頂,且點擊啟動圖標時只有 Launcher Activity 可能會被回調(diào) onNewIntent 方法!作者本人始終沒找到系統(tǒng)中有別的接口來更新或者獲取最新的本應(yīng)用圖標的位置信息!
正因如此,所以我最后得出的結(jié)論是我們無法給整個 App 加上一個完美的揭露動畫效果,只能在有限的條件下(不去太關(guān)心應(yīng)用啟動圖標的位置隨時可能被用戶更改這件事)給 App 加上一個乍看上去還行的揭露動畫效果,如上圖所示!
全文終。
未完待續(xù)。