Android 的揭露(CircularReveal)動畫

完整叫法應(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 的揭露效果:

App揭露

Activity 的揭露效果:

Activity揭露

普通 View 的揭露效果:

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揭露

基本使用如此,為界面中的某個 View 應(yīng)用揭露動畫效果還是很簡單的,下面我們看看如何為應(yīng)用內(nèi) Activity 間的切換(上面Activity 的揭露效果一圖)應(yīng)用我們炫酷(主觀上的)的揭露動畫效果。

Activity 級別的揭露動畫

首先我們會遇到兩個問題:

  1. 揭露動畫用于 Activity 切換時,我們該把揭露動畫應(yīng)用于哪個 View(揭露動畫的應(yīng)用對象必須是一個 View)?
  2. 何時開始執(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揭露

還有很重要的一點,因為 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)坐標隨時會變化的啟動圖標。大概效果會是這樣:

App揭露

答案是很遺憾,經(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ù)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容