1. 程式人生 > >[譯] 在 Android 上實現 Google Inbox 的樣式動畫

[譯] 在 Android 上實現 Google Inbox 的樣式動畫

作為一個 Android 使用者和開發人員,我總是被精美的應用程式所吸引,這些應用程式具有漂亮而有意義的動畫。對我來說,這樣的應用程式不僅擁有了強大的功能,使使用者的生活更便捷,同時還表現出他們背後的團隊為了將使用者體驗提升一個層次所投入的精力和熱情。我經常享受體驗這些動畫,然後花費數小時時間去試圖複製它們。其中一個應用程式是 Google Inbox,它提供了一個漂亮的電子郵件開啟/關閉動畫,如下所示(如果你不熟悉它)。

在本文中,我將帶您體驗在 Android 上覆制動畫的旅程。


設定

為了複製動畫,我構建了一個簡單的帶有 2 個 fragment 的應用程式 ,如下所示分別是 Email List fragment 和 Email Details fragment。

電子郵件列表 InProgress 狀態(左)- 電子郵件列表 Success 狀態(中)- 電子郵件詳細資訊(右)

為了類比電子郵件獲取網路請求,我為 Email List fragment 建立了一個 [ViewModel](https://developer.android.com/reference/android/arch/lifecycle/ViewModel)

,它生成了 2 個狀態,InProgress 表示正在獲取電子郵件,Success 表示電子郵件資料已成功獲取並準備好呈現(網路請求被模擬為 2 秒)。

sealed class State {
  object InProgress : State()
  data class Success(val data: List<String>) : State()
}
複製程式碼

Email List fragment 有一種方法來呈現這些狀態,如下所示。

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,並將使用者從電子郵件詳細資訊中帶回電子郵件列表。

現在開始我們的旅程吧...

第一站 - 那是什麼樣的動畫?

有一點是可以立刻確定的就是他是一種 [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)
    }
複製程式碼

這是我得到的(電子郵件詳細資訊檢視的背景設定為藍色,以便清楚地演示過渡效果)...

當然這不是我想要的。這裡有兩個問題。

  1. 電子郵件專案不會同時開始轉換。遠離被點選條目的 items 過度的更快。
  2. 被點選的電子郵件專案上的共享元素轉換與其他專案的轉換不同步,即,當分別展開時,Email 4Email 6 應始終貼上在藍色矩形的頂部和底部邊緣。但他們沒有!

所以究竟哪裡出了問題?

第二站:開箱即用的 Explode 效果不是我想要的。

在深入研究 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 4Email 6 分別靠近頂部和底部邊緣時,它們會減慢速度。這表明 SlideExplode 過渡正常。

但是,Explode 轉換和共享元素轉換仍未同步。我們可以看到他們正在以不同的模式移動,這表明他們的插值器可能不同。前一個過渡開始非常快,最後減速,而後者一開始很慢,一段時間後加速。

但是怎麼樣?我確實在程式碼中將插值器設定相同了!

第三站:原來是 TransitionSet 的鍋!

我再次深入研究原始碼。這次我發現每當我將插值器設定為 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 被銷燬和程序被殺死的情況

過渡動畫現在可以在方向變化中存活,但在 activity 被銷燬或者程序被殺死時又會有什麼樣的效果呢?在我們的特定方案中,電子郵件列表 viewModel 在任何一種情況下都不存活,因此電子郵件資料也不存在。我們的轉換取決於所點選的電子郵件專案的位置,因此如果資料丟失則無法使用。

奇怪的是,我查看了幾個著名的應用程式,看看它們在這種情況下如何處理轉換:

  • Google Inbox:有趣的是,它不需要處理這種情況,因為它會在活動被銷燬後重新載入電子郵件列表(而不是電子郵件詳細資訊)。
  • Google Play:活動銷燬或處理死亡後沒有反向共享元素轉換。
  • Plaid (不是一個真正的應用程式,但卻是 Android 上的一個優秀的 material design 的 demo):即使在方向改變之後(截至編寫時),也沒有反向共享元素過渡。

雖然上面的列表沒有足夠的結論來處理 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 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄