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

image.png

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


設置

為了復制動畫,我構建了一個簡單的帶有2個片段的應用程序,如下所示分別是電子郵件列表片段和電子郵件詳細資訊片段。

image

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

為了模擬電子郵件獲取網路請求,我為電子郵件列表片段創建了一個[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()
}

電子郵件列表片段有一種方法來呈現這些狀態,如下所示。

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)
      }
}

每當電子郵件列表片段被新加載時,都會獲取電子郵件數據並呈現InProgress狀態,直到電子郵件數據可用(Success狀態)。點擊電子郵件列表中的任何電子郵件項目將使用戶進入電子郵件詳情片段,並將用戶從電子郵件詳細資訊中帶回電子郵件列表。

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

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

有一點是可以立刻確定的就是他是一種[Explode](https://developer.android.com/reference/android/transition/Explode)過渡動畫,因為在被點擊的項目上下的項目有過度。但是等一下,電子郵件詳細資訊檢視也會從點擊的電子郵件項目進行轉換和擴展。意味著還有一個共享元素轉換。結合我說的,下面是我做出的第一次嘗試。

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)
    }

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

image

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

  1. 電子郵件項目不會同時開始轉換。遠離被點擊條目的項目過度的更快。
  2. 被點擊的電子郵件項目上的共享元素轉換與其他項目的轉換不同步,即,當分別展開時,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,讓我們再試一次。

image

這樣就好多了!上面和下面的項目現在開始同時轉換。請注意,由於插值器設置為FastOutSlowIn,因此當Email 4狀語從句:Email 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
}

讓我們在更新插值器的設置後再試一次。

image

YAYYYY!現在看起來很正確。但反向過渡怎麼樣?

image

沒有達到我想要的結果!爆炸過渡似乎有效。但是,共享元素過渡沒有。

第四站:延後進入轉換

反向過渡動畫不起作用的原因是它發揮得太早。對於任何過渡的工作,它需要捕獲目標視圖的開始和結束狀態(大小,位置,範圍),在這種情況下,的英文它們Email Details視圖狀語從句:Email 5 item項。在如果Email 5 item的狀態可用之前啟動了反向轉換,則它將無法像我們所看到的那樣正常運行。

這里的解決方案是延後反向轉換,直到items are被繪制完。幸運的是,transition框架提供了一對postponeEnterTransition方法,它向系統標記輸入過渡應該被延後,startPostponedEnterTransition表示它可以啟動。請注意,必須在調用startPostponedEnterTransition後的某個時間調用postponeEnterTransition。否則,將永遠不會執行過渡動畫,並且fragment也不會彈出。

根據我們的設置,每當從電子郵件詳細資訊片段重新進入電子郵件列表片段時,它會從視圖模型中獲取最新狀態並立即呈現電子郵件列表。因此,如果我們延後過渡動畫,直到呈現電子郵件列表,等待時間不會太長(從死進程中恢復並彈出是一個不同的情況。這將在後面的帖子中介紹)。

更新後的代碼如下所示。我們延後了onViewCreated中的輸入轉換。

override fun onViewCreated(view: View, savedState: Bundle?) {
  super.onViewCreated(view, savedInstanceState)
  postponeEnterTransition()
  ...
}

並在渲染狀態後開始延後過渡。這是使用doOnPreDraw完成的。

is Success -> {
  ...
  (view?.parent as? ViewGroup)?.doOnPreDraw {
    startPostponedEnterTransition()
  }
}

image

現在它成功了!但當方向變換時這個過度效果還會存在嗎?

第五站:位置方向改變

轉換後,電子郵件列表片段並沒有發生反轉過渡動畫。經過一些調試後,我發現當片段的方向發生改變時,過渡動畫也被銷毀了。因此,應在片段被銷毀後重新創建過渡動畫。此外,由於熒幕尺寸和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在任何一種情況下都不存活,因此電子郵件數據也不存在。我們的轉換取決於所點擊的電子郵件項目的位置,因此如果數據丟失則無法使用。

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

Google Inbox:有趣的是,它不需要處理這種情況,因為它會在活動被銷毀後重新加載電子郵件列表(而不是電子郵件詳細資訊)。
Google Play:活動銷毀或處理死亡後沒有反向共享元素轉換。
Plaid(不是一個真正的應用程序,但卻是Android上的一個優秀的材料設計的演示):即使在方向改變之後(截至編寫時),也沒有反向共享元素過渡。
雖然上面的列表沒有足夠的結論來處理Android應用程序在這種情況下處理轉換的模式,但它至少顯示了一些觀點。

回到我們的具體問題,通常有兩種可能性取決於每個應用程序處理此類情況的方法:(1)忽略丟失的數據並重新獲取數據,以及(2)保留數據並恢復數據由於這篇文章主要是關於過渡動畫,所以我不打算討論在什麼情況下哪種方法更好以及為什麼等。如果採用方法(1),則不應該進行反向轉換,因為我們不知道先前被點擊的電子郵件項目是否會被取回,即使知道,我們不知道它在列表中的位置。如果採用方法(2),我們可以像定向改變方案那樣進行轉換。

方法(1)是我在這種特定情況下的偏好,因為新的電子郵件可能每分鐘都會出現,因此在活動銷毀或處理死亡之後重新加載過時的電子郵件列表是沒有用的,這通常發生在用戶離開應用程序一段時間之後。在我們的設置中,當活動被毀毀或進程被殺死後後重新創建電子郵件列表片段時,將自動獲取電子郵件數據,因此不需要做太多工作。我們只確保需要在呈現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()
    }
  }
}

過渡動畫現在看起來如下。

image

他已經被完全復制了嗎?

基本上是。唯一缺少的是能夠垂直滑動電子郵件詳細資訊視圖以顯示電子郵件列表中的其他電子郵件,並通過釋放手指觸發反向過渡,就和下面的GIF圖所展示的效果一樣。
這樣的動畫對我來說很有意義,因為如果用戶可以點擊電子郵件項目來打開/展開它,他自然會拖下電子郵件詳細資訊來隱藏/摺疊它。目前我正在探索實現這種效果的幾個選項,它們將在下一篇文章中討論。

想學習更多Android知識,或者獲取相關資料請加入Android技術開發交流2群:935654177。本群可免費獲取Gradle,RxJava,小程序,Hybrid,移動架構,NDK,React Native,性能優化等技術教程!