- 原文地址:Implement Google Inbox Style Animation on Android
- 原文做者:Huan Nguyen
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:YueYong
- 校對者:zx-Zhu
做爲一個 Android 用戶和開發人員,我老是被精美的應用程序所吸引,這些應用程序具備漂亮而有意義的動畫。對我來講,這樣的應用程序不只擁有了強大的功能,使用戶的生活更便捷,同時還表現出他們背後的團隊爲了將用戶體驗提高一個層次所投入的精力和熱情。我常常享受體驗這些動畫,而後花費數小時時間去試圖複製它們。其中一個應用程序是 Google Inbox,它提供了一個漂亮的電子郵件打開/關閉動畫,以下所示(若是你不熟悉它)。html
在本文中,我將帶您體驗在 Android 上覆制動畫的旅程。前端
爲了複製動畫,我構建了一個簡單的帶有 2 個 fragment 的應用程序 ,以下所示分別是 Email List fragment 和 Email Details fragment。android
電子郵件列表 InProgress 狀態(左)- 電子郵件列表 Success 狀態(中)- 電子郵件詳細信息(右)ios
爲了模擬電子郵件獲取網絡請求,我爲 Email List fragment 建立了一個 [ViewModel](https://developer.android.com/reference/android/arch/lifecycle/ViewModel)
,它生成了 2 個狀態,InProgress
表示正在獲取電子郵件,Success
表示電子郵件數據已成功獲取並準備好呈現(網絡請求被模擬爲 2 秒)。git
sealed class State {
object InProgress : State()
data class Success(val data: List<String>) : State()
}
複製代碼
Email List fragment 有一種方法來呈現這些狀態,以下所示。github
private fun render(state: State) {
when (state) {
is InProgress -> {
emailList.visibility = GONE
progressBar.visibility = VISIBLE
}
is Success -> {
emailList.visibility = VISIBLE
progressBar.visibility = GONE
emailAdapter.setData(state.data)
}
}
複製代碼
每當 Email List fragment 被新加載時,都會獲取電子郵件數據並呈現 InProgress
狀態,直到電子郵件數據可用(Success
狀態)。點擊電子郵件列表中的任何電子郵件項目將使用戶進入 Email Details fragment,並將用戶從電子郵件詳細信息中帶回電子郵件列表。後端
如今開始咱們的旅程吧...bash
有一點是能夠馬上肯定的就是他是一種 [Explode](https://developer.android.com/reference/android/transition/Explode)
過渡動畫,由於在被點擊的 item 上下的 item 有過分。可是等一下,電子郵件詳細信息 view 也會從點擊的電子郵件項目進行轉換和擴展。這意味着還有一個共享元素轉換。結合我說的,下面是我作出的第一次嘗試。網絡
override fun onBindViewHolder(holder: EmailViewHolder, position: Int) {
fun onViewClick() {
val viewRect = Rect()
holder.itemView.getGlobalVisibleRect(viewRect)
exitTransition = Explode().apply {
duration = TRANSITION_DURATION
interpolator = transitionInterpolator
epicenterCallback = object : Transition.EpicenterCallback() {
override fun onGetEpicenter(transition: Transition) = viewRect
}
}
val sharedElementTransition = TransitionSet()
.addTransition(ChangeBounds())
.addTransition(ChangeTransform())
.addTransition(ChangeImageTransform()).apply {
duration = TRANSITION_DURATION
interpolator = transitionInterpolator
}
val fragment = EmailDetailsFragment().apply {
sharedElementEnterTransition = sharedElementTransition
sharedElementReturnTransition = sharedElementTransition
}
activity!!.supportFragmentManager
.beginTransaction()
.setReorderingAllowed(true)
.replace(R.id.container, fragment)
.addToBackStack(null)
.addSharedElement(holder.itemView, getString(R.string.transition_name))
.commit()
}
holder.bindData(emails[position], ::onViewClick)
}
複製代碼
這是我獲得的(電子郵件詳細信息視圖的背景設置爲藍色,以便清楚地演示過渡效果)...app
固然這不是我想要的。這裏有兩個問題。
Email 4
和 Email 6
應始終粘貼在藍色矩形的頂部和底部邊緣。但他們沒有!因此究竟哪裏出了問題?
在深刻研究 Explode
源代碼後,我發現了兩個有趣的事實:
CircularPropagation
來強制執行這樣一條規則,即,當它們從屏幕上消失時,離中心遠的視圖過渡速度會地比離中心近的視圖快。Explode
過渡的中心被設置爲覆蓋被點擊的電子郵件項目的矩形。這解釋了爲何未打開的電子郵件項目視圖不會如上所述一塊兒轉換。因此我決定編寫本身的 Explode
過渡。我將它命名爲 SlideExplode
,由於它與 Slide
過渡很是類似,只是有 2 個部分在 2 個相反的方向上移動。
import android.animation.Animator
import android.animation.ObjectAnimator
import android.graphics.Rect
import android.transition.TransitionValues
import android.transition.Visibility
import android.view.View
import android.view.ViewGroup
private const val KEY_SCREEN_BOUNDS = "screenBounds"
/**
* A simple Transition which allows the views above the epic centre to transition upwards and views
* below the epic centre to transition downwards.
*/
class SlideExplode : Visibility() {
private val mTempLoc = IntArray(2)
private fun captureValues(transitionValues: TransitionValues) {
val view = transitionValues.view
view.getLocationOnScreen(mTempLoc)
val left = mTempLoc[0]
val top = mTempLoc[1]
val right = left + view.width
val bottom = top + view.height
transitionValues.values[KEY_SCREEN_BOUNDS] = Rect(left, top, right, bottom)
}
override fun captureStartValues(transitionValues: TransitionValues) {
super.captureStartValues(transitionValues)
captureValues(transitionValues)
}
override fun captureEndValues(transitionValues: TransitionValues) {
super.captureEndValues(transitionValues)
captureValues(transitionValues)
}
override fun onAppear(sceneRoot: ViewGroup, view: View,
startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (endValues == null) return null
val bounds = endValues.values[KEY_SCREEN_BOUNDS] as Rect
val endY = view.translationY
val startY = endY + calculateDistance(sceneRoot, bounds)
return ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, startY, endY)
}
override fun onDisappear(sceneRoot: ViewGroup, view: View,
startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (startValues == null) return null
val bounds = startValues.values[KEY_SCREEN_BOUNDS] as Rect
val startY = view.translationY
val endY = startY + calculateDistance(sceneRoot, bounds)
return ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, startY, endY)
}
private fun calculateDistance(sceneRoot: View, viewBounds: Rect): Int {
sceneRoot.getLocationOnScreen(mTempLoc)
val sceneRootY = mTempLoc[1]
return when {
epicenter == null -> -sceneRoot.height
viewBounds.top <= epicenter.top -> sceneRootY - epicenter.top
else -> sceneRootY + sceneRoot.height - epicenter.bottom
}
}
}
複製代碼
如今我已經爲 SlideExplode
交換了 Explode
,讓咱們再試一次。
這樣就好多了!上面和下面的項目如今開始同時轉換。請注意,因爲插值器設置爲 FastOutSlowIn
,所以當 Email 4
和 Email 6
分別靠近頂部和底部邊緣時,它們會減慢速度。這代表 SlideExplode
過渡正常。
可是,Explode
轉換和共享元素轉換仍未同步。咱們能夠看到他們正在以不一樣的模式移動,這代表他們的插值器可能不一樣。前一個過渡開始很是快,最後減速,然後者一開始很慢,一段時間後加速。
可是怎麼樣?我確實在代碼中將插值器設置相同了!
我再次深刻研究源代碼。此次我發現每當我將插值器設置爲 TransitionSet
時,它都不會在過渡的時候將插值器分配給它。這僅在標準 TransitionSet中
發生。它的支持版本(android.support.transition.TransitionSet
)正常工做。要解決此問題,咱們能夠切換到支持版本,或者使用下面的擴展函數將插值器明確地傳遞給包含的轉換。
fun TransitionSet.setCommonInterpolator(interpolator: Interpolator): TransitionSet {
(0 until transitionCount)
.map { index -> getTransitionAt(index) }
.forEach { transition -> transition.interpolator = interpolator }
return this
}
複製代碼
讓咱們在更新插值器的設置後再試一次。
YAYYYY!如今看起來很正確。但反向過渡怎麼樣?
沒有達到我想要的結果!Explode 過渡彷佛有效。可是,共享元素過渡沒有。
反向過渡動畫不起做用的緣由是它發揮得太早。對於任何過渡的工做,它須要捕獲目標視圖的開始和結束狀態(大小,位置,範圍),在這種狀況下,它們是 Email Details
視圖和 Email 5 item
項。若是在 Email 5 item
的狀態可用以前啓動了反向轉換,則它將沒法像咱們所看到的那樣正常運行。
這裏的解決方案是推遲反向轉換,直到 items 都被繪製完。幸運的是,transition 框架提供了一對 postponeEnterTransition
方法,它向系統標記輸入過渡應該被推遲,startPostponedEnterTransition
表示它能夠啓動。請注意,必須在調用 startPostponedEnterTransition
後的某個時間調用 postponeEnterTransition
。不然,將永遠不會執行過渡動畫,而且 fragment 也不會彈出。
根據咱們的設置,每當從 Email Details fragment 從新進入 Email List fragment 時,它會從 view model 中獲取最新狀態並當即呈現電子郵件列表。所以,若是咱們推遲過渡動畫,直到呈現電子郵件列表,等待時間不會太長(從死進程中恢復並彈出是一個不一樣的狀況。這將在後面的帖子中介紹)。
更新後的代碼以下所示。咱們推遲了 onViewCreated
中的 enter 轉換。
override fun onViewCreated(view: View, savedState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postponeEnterTransition()
...
}
複製代碼
並在渲染狀態後開始推遲過渡。這是使用 doOnPreDraw 完成的。
is Success -> {
...
(view?.parent as? ViewGroup)?.doOnPreDraw {
startPostponedEnterTransition()
}
}
複製代碼
如今它成功了!但當方向變換時這個過分效果還會存在嗎?
轉換後,Email List fragment 並無發生反轉過渡動畫。通過一些調試後,我發現當 fragment 的方向發生改變時,過渡動畫也被銷燬了。所以,應在 fragment 被銷燬後從新建立過渡動畫。此外,因爲屏幕尺寸和 UI 差別,Explode
的過渡中心在縱向和橫向模式下一般是不相同的。所以咱們也須要更新中心區域。
這要求咱們跟蹤點擊項目的位置並在方向更改時從新記錄,這將致使更新的代碼以下。
override fun onViewCreated(view: View, savedState: Bundle?) {
super.onViewCreated(view, savedState)
tapPosition = savedState?.getInt(TAP_POSITION, NO_POSITION) ?: NO_POSITION
postponeEnterTransition()
...
}
...
private fun render(state: State) {
when (state) {
...
is Success -> {
...
(view?.parent as? ViewGroup)?.doOnPreDraw {
if (exitTransition == null) {
exitTransition = SlideExplode().apply {
duration = TRANSITION_DURATION
interpolator = transitionInterpolator
}
}
val layoutManager = emailList.layoutManager as LinearLayoutManager
layoutManager.findViewByPosition(tapPosition)?.let { view ->
view.getGlobalVisibleRect(viewRect)
(exitTransition as Transition).epicenterCallback =
object : Transition.EpicenterCallback() {
override fun onGetEpicenter(transition: Transition) = viewRect
}
}
startPostponedEnterTransition()
}
}
}
}
...
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(TAP_POSITION, tapPosition)
}
複製代碼
過渡動畫如今能夠在方向變化中存活,但在 activity 被銷燬或者進程被殺死時又會有什麼樣的效果呢?在咱們的特定方案中,電子郵件列表 viewModel 在任何一種狀況下都不存活,所以電子郵件數據也不存在。咱們的轉換取決於所點擊的電子郵件項目的位置,所以若是數據丟失則沒法使用。
奇怪的是,我查看了幾個著名的應用程序,看看它們在這種狀況下如何處理轉換:
雖然上面的列表沒有足夠的結論來處理 Android 應用程序在這種狀況下處理轉換的模式,但它至少顯示了一些觀點。
回到咱們的具體問題,一般有兩種可能性取決於每一個應用程序處理此類狀況的方法:(1)忽略丟失的數據並從新獲取數據,以及(2)保留數據並恢復數據。因爲這篇文章主要是關於過渡動畫,因此我不打算討論在什麼狀況下哪一種方法更好以及爲何等。若是採用方法(1),則不該該進行反向轉換,由於咱們不知道先前被點擊的電子郵件項目是否會被取回,即便知道,咱們不知道它在列表中的位置。若是採用方法(2),咱們能夠像定向改變方案那樣進行轉換。
方法(1)是我在這種特定狀況下的偏好,由於新的電子郵件可能每分鐘都會出現,所以在活動銷燬或處理死亡以後從新加載過期的電子郵件列表是沒有用的,這一般發生在用戶離開應用程序一段時間以後。在咱們的設置中,當activity 被銷燬或進程被殺死後後從新建立電子郵件列表片斷時,將自動獲取電子郵件數據,所以不須要作太多工做。咱們只須要確保在呈現 InProgress
狀態時調用 startPostponedEnterTransition
:
is InProgress -> {
...
(view?.parent as? ViewGroup)?.doOnPreDraw {
startPostponedEnterTransition()
}
}
複製代碼
到目前爲止,咱們已經有了一個基本的 「Inbox style」 過渡。有不少方法實現平滑。一個例子是在展開細節時呈現淡入效果,相似於收件箱應用程序的功能。這能夠經過如下方式實現:
class EmailDetailsFragment : Fragment() {
...
override fun onViewCreated(view: View, savedState: Bundle?) {
super.onViewCreated(view, savedState)
val content = view.findViewById<View>(R.id.content).also { it.alpha = 0f }
ObjectAnimator.ofFloat(content, View.ALPHA, 0f, 1f).apply {
startDelay = 50
duration = 150
start()
}
}
}
複製代碼
過渡動畫如今看起來以下。
基本上是。惟一缺乏的是可以垂直滑動電子郵件詳細信息視圖以顯示電子郵件列表中的其餘電子郵件,並經過釋放手指觸發反向過渡,就和下面的 GIF 圖所展現的效果同樣。
這樣的動畫對我來講頗有意義,由於若是用戶能夠點擊電子郵件項目來打開/展開它,他天然會拖下電子郵件詳細信息來隱藏/摺疊它。目前我正在探索實現這種效果的幾個選項,它們將在下一篇文章中討論。
那就這樣吧。實現動畫是 Android 開發中一個具備挑戰性但又有趣的部分。我但願你喜歡和我同樣喜歡動畫。源代碼能夠在這裏找到。歡迎提出反饋/意見/討論!
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。