[译] 在 Android 上实现 Google Inbox 的样式动画

做为一个 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

固然这不是我想要的。这里有两个问题。

  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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索