情感智能。构建更智能的动画
最近,在Google I/O会议上,我介绍了一些在 Android 应用程序中编写更智能的动画的技术,特别是使动画与反应式架构完美配合的技术。
tl;dw?
我知道观看32分钟的视频并不是每个人都喜欢的,所以这里有一个关于这个主题的写法。☕️
#AnimationsMatter(动画的重要性
我认为动画对你的应用程序的可用性非常重要;它们解释了状态变化或过渡,建立了空间模型或可以引导注意力。它们帮助用户理解和浏览我们的应用程序。
有动画&的实验,没有👉的实验。
这个例子显示了一个应用程序中的相同流程--左边是动画的启用,右边是动画的禁用。在没有动画的情况下,体验感觉很突兀;在不同的状态之间跳转,却没有解释发生了什么变化。
因此,虽然我认为动画很重要,但我也认为,由于现代应用程序的架构方式发生了变化,动画变得越来越难。我们正在将大多数状态管理从视图层转移到一些控制器(如ViewModel
),然后发布某种状态对象,例如UiModel
,它封装了渲染视图所需的应用程序的当前状态。每当我们的数据模型发生变化时,例如,一个网络请求返回或一个用户发起的动作完成,我们就会发布一个新的UI模型,封装整个更新的状态。
A ViewModel
发布一个状态对象的流
我今天不想专注于这种模式或其好处。在这方面有很多很好的资源:寻找Uni-directional Data Flow或MVI,或像 MvRx或 Mobius这样的库。但我确实想关注这个流的另一端:视图观察这个模型流并将它们绑定到用户界面。这就像一个纯粹的函数,给定一个新的状态,我们想把它完全绑定到我们的用户界面。我们不想去考虑UI的当前状态。也就是说,数据与你的用户界面的绑定应该是状态少。然而,动画是状态ful。它们都是关于随着时间的推移从一个值移动到另一个值。这是我想在这篇文章中关注的基本矛盾。因为现在我担心许多应用程序因为这种矛盾而取消了动画,导致可用性的真正损失。
…binding data to your UI wants to be stateless. Animations however are stateful.
问题出在哪里?
为了具体了解我们如何在这个反应式世界中保留动画,以及我们需要解决的挑战,这里有一个最简单的例子:一个登录屏幕。
一个登录屏幕,其中登录按钮和进度指示器在变得可见/不可见时淡入/淡出。
当用户点击登录时,我们想隐藏登录按钮并显示一个进度指示器,但我们想淡出登录按钮并淡入进度指示器。
这个屏幕的状态对象和(静态)绑定逻辑可能看起来像这样的。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
data class LoginUiModel(
val submitEnabled: Boolean,
val loginInProgress: Boolean
)
…
setVisibility(login, !uiModel.loginInProgress)
setVisibility(progress, uiModel.loginInProgress)
…
fun setVisibility(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.GONE
}
因此,如果我们想要动画化这种变化,最初的尝试可能看起来像这样,我们将alpha属性动画化(在淡出的情况下,最后设置可见性值)。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun animateVisibility(view: View, visible: Boolean) {
view.visibility = View.VISIBLE
if (visible) {
ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).start()
} else {
ObjectAnimator.ofFloat(view, View.ALPHA, 1f, 0f).apply {
doOnEnd { view.visibility = View.GONE }
}.start()
}
}
然而,这可能会导致意想不到的结果。
在反应式应用程序中添加动画时可能遇到的那种问题
在这里,一个新的UI模型在每一次按键时都会被发布,但是你可以看到进度指示器一直在意外地显示出来或者,如果你按下提交按钮(为了演示,用夸张的动画持续时间),我们会在一个糟糕的状态下结束,按钮和进度指示器都消失了。这是因为我们的动画有副作用,比如end listener,没有被正确处理。
在这个反应式世界中编写动画时,你的动画代码需要具备一些品质。我将它们归类为:。
- 再入式
- 连续的
- 光滑
再入式
可重入性意味着你的动画需要准备好在任何时候被中断并再次调用。如果新的状态对象可以在任何时候被发布,那么我们运行的任何动画都需要为新的状态做好准备,以便在动画运行的同时绑定。为了做到这一点,我们需要能够取消或重新定位任何正在运行的动画,并清理任何副作用(如监听器)。
连贯性
连续性是指避免被动画化的值发生突然的变化。为了演示这个属性,请考虑一个视图,当按下/释放时,它的比例和颜色将被动画化。
在印刷机上对比例和颜色进行动画化处理
当我们运行动画到完成时,一切看起来都很好,但如果我们快速点击,我们会看到动画的大小和颜色都在跳跃。这是在我们的绑定代码中做出假设的结果,例如,假设一个淡出的动画总是从0 alpha开始。
光滑度
为了理解这个属性,请考虑这个例子,在这个例子中,一个视图响应一个事件而向左上方或右上方移动。
结结巴巴的动画
如果我们连续两次把它送到右上方,那么视图就会在中途停止,然后再慢慢地继续向目的地前进。如果我们在飞行过程中改变目的地,那么它又会停下来并突然改变方向。这种突然停止或改变方向的行为显得很不自然--现实世界中没有任何东西是这样的。我们应该避免这类行为,以保持我们的动画流畅。
Fixme(修复
因此,让我们回到我们的可见性绑定函数并解决这些问题。首先,让我们看看continuity。我们可以看到,我们的alpha动画总是从一个初始值运行到一个最终值,例如从0到1的淡入。我们可以省略初始值,只提供一个最终值。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
- ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).start()
+ ObjectAnimator.ofFloat(view, View.ALPHA, 1f).start()
如果您省略初始值,那么动画师将读取当前值并从那里开始。这正是我们想要的,并将避免被动画化的属性出现任何突然的跳跃。
现在让我们把我们的函数变成reentrant;在这里它可以在任何时候安全地再次调用。首先,我们可以偷懒,避免做任何我们不需要做的工作。如果视图已经达到了目标值,那么我们可以提前返回。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun animateVisibility(view: View, visible: Boolean) {
+ val targetAlpha = if (visible) 1f else 0f
+ if (view.alpha == targetAlpha) return
接下来我们需要存储正在运行的动画器和监听器,这样我们就可以在开始一个新的动画之前取消它们。合理的存储位置是在视图本身… 但View
已经提供了一个方便的机制来做这件事。ViewPropertyAnimator
.这是调用View.animate()
返回的对象,如果你开始一个新的动画,它就会自动取消一个属性上当前正在运行的动画— 棒极了!ViewPropertyAnimator
还提供了一个withEndAction
方法,它只在动画正常运行到完成时运行,不如果它被取消了。这也正是我们想要的行为,这意味着如果动画被一个新的目标值取消,任何副作用(比如我们的可见性变化)都不会运行。切换到一个ViewPropertyAnimator
使得我们的函数具有可重入性。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
- val anim = ObjectAnimator.ofFloat(view, View.ALPHA, targetAlpha)
- if (!visible) {
- anim.doOnEnd { view.visibility = View.GONE }
- }
+ val anim = view.animate().alpha(targetAlpha)
+ if (!visible) {
+ anim.withEndAction { view.visibility = View.GONE }
+ }
我们说过,ViewPropertyAnimator
会取消任何在同一属性上运行的动画,并启动一个新的动画。这违反了我们的smoothness属性,并可能导致我们之前看到的卡顿问题,即一个动画突然停止,另一个动画开始(持续时间相同,尽管它的距离较短)。为了解决这个问题,我们可以看看一个动画库,我想很多开发者并不熟悉这个库。
春天的序曲
弹簧是‘动态-动画’Jetpack库的一部分。我想很多人在看到非常有弹性的动画的例子时可能会跳过这个库—虽然这种效果可能很有用,但并不总是需要或可取的。然而,这种弹跳性可以被停用,留给我们一个由物理模型支持的动画系统,该物理模型有许多属性,是对一般动画有用;特别是可中断性和重新定位。
所以回到我们之前的例子,如果我们用弹簧动画重新实现,我们可以看到它不会受到平滑度问题的影响。相反,它处理了改变目的地和重复启动的问题,尊重了当前的速度以产生平滑的动画。
基于弹簧的动画在被重新定位时能保持速度
编写一个SpringAnimation
看起来很像一个普通的Animator
;大部分的好处来自于使用animateToFinalPosition
方法而不是调用start()
。如果动画还没有开始,这将启动一个动画,但关键是如果有一个动画正在进行,它将retarget它到一个新的目的地,保持势头而不是突然改变。
不幸的是,没有像View.animate
那样方便的View
API来使用弹簧(它只有Jetpack)… 但我们可以建立一个扩展函数。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun View.spring(
property: ViewProperty
): SpringAnimation {
val key = getKey(property)
var springAnim = getTag(key) as? SpringAnimation?
if (springAnim == null) {
springAnim = SpringAnimation(this, property)
setTag(key, springAnim)
}
return springAnim
}
这为给定的ViewProperty
(翻译、旋转等)创建或检索了一个弹簧,将其存储在视图’的标签中。然后我们可以轻松地使用animateToFinalPosition
方法来更新一个正在运行的动画。在我们的可见性绑定函数中使用这个方法。
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
- val anim = view.animate().alpha(targetAlpha)
+ val spring = view.spring(SpringAnimation.ALPHA)
+ spring.animateToFinalPosition(targetAlpha)
我们还需要将结束动作切换为使用弹簧动画的结束监听器。你可以在这个gist找到完整的代码。你也可能希望能够在某种程度上配置动画;与指定持续时间和插值器的常规动画不同,弹簧可以通过设置其刚度和阻尼比来进行配置。我们可以加强我们的扩展函数,接受参数,使其易于配置,但提供合理的默认值。请看这里一个更完整的实现。
因此,我们已经使我们的可见性绑定变得可重入、连续和流畅。虽然实现这一目标看起来很复杂,但实际上你只需要几个这样的绑定函数,然后就可以在你的应用程序中使用。这里有一个库,它将这个春天的技术打包起来,便于使用。
项目动画制作者
让我们看一下使用这种动画的另一个例子;将这些原则应用于RecyclerView.ItemAnimator
。
在DefaultItemAnimator与基于Spring的ItemAnimator之间的竞争中 👉
这个例子模拟了在使用shuffle按钮运行动画时发生在数据集上的更新。请注意,当我们快速连续点击两次按钮时,基于弹簧的动画器的平滑性产生了很大的不同。在左边,盒子停滞然后改变方向。在右边,它平稳地改变了方向。我敢打赌,大多数应用程序从网络上加载信息,并以RecyclerView
的方式显示,很可能来自多个来源。这种灵活的动画为你的应用程序增加了一个层次,使体验更加顺畅。这里有一个PR将这种动画器添加到Plaid样本。
动画片更聪明
希望我在这篇文章中阐述的原则能够帮助你在你的反应式应用中编写动画,提高其可用性。实际上,它们是一个有序的列表。
@crafty’的动画需求层次"。
可重入性是关于正确性的,如果你没有这个属性,那么你的动画可能会被破坏。使用ViewPropertyAnimator
或者在你的动画代码中小心处理它可能被打断并再次调用的情况。
连续性有助于改善用户体验,避免突然的变化或跳跃。这是为了避免你的动画代码中的假设,并确保动画之间的轻松交接。
流畅性是锦上添花的东西🎂。它使你的动画感觉更自然,并实现动态变化、中断和重新定位。
我真的相信,动画不仅使我们的应用程序更令人愉悦,使用起来更愉快,而且更容易理解。我鼓励你学习这些技术,以便你能在你的应用程序中成功运用它们。
正在等待Jetpack Compose的出现。动画将是小菜一碟,也将适合反应式架构。
嗨,尼克。在我看到你在I/O'19的会议后,我想问问。
动画与动画师之间的主要区别是什么?
据我所知,Animator会改变它的属性,而且Animation只对draw path起作用,这意味着Animation会复制属性的当前状态,并且几乎像捕捉和动画一样。我还听说Animation对视图的测量是有保证的。这个概念是否也可以应用于Animator?
Play Store上的大多数顶级应用都没有使用Motional Intelligence。
你的谷歌应用程序中的这些动画在哪里?还有那些关于材料设计的废话?
这实在是太蹩脚了。
太糟糕了,Q转换仍然是坏的,而且可能在发布时也会是这样:)
我觉得动画不应该是这么难写的。