如何在Android上编写具有MotionLayout的复杂UI/动画

使用运动布局(和协同程序)探索复杂的多步动画。

https://thumbs.gfycat.com/HairyWellwornGelding-mobile.mp4

运动布局是在动画、过渡、复杂动作以及你拥有的功能上的新助手。本文中,我们将研究运动布局和Coroutines是如何帮助我们构建多步动画的。

上一篇文章深入探讨了没有使用运动布局的不同动画和小部件,我希望在阅读本文之前你能看看之前的文章,因为:

1.在本文中,我们将只讨论过滤器工作表转换,而不会讨论适配器、选项卡和其他动画。

2.你会理解和欣赏使用运动布局编写这些动画和不使用时的区别。

Android上复杂的UI/动画
如何在Adnroid上编写复杂的多步动画.
proandroiddev.com

开始之前

什么是运动布局?快速介绍…

1_4ddULlE7YKRVeneFY2IDqw

简而言之, 运动布局 是一个允许你可以轻松在两个ConstraintSet之间进行转换的 ConstraintLayout

<ConstraintSet> 包含每个视图的所有约束和布局属性。

<Transition> 指定要在其之间进行过渡的起始ConstraintSets。

将所有这些都放入 <MotionScene> 文件中,你就可以 拥有一个运动布局啦!

随着布局和动画变得越来越复杂,MotionScene也变得越来越复杂,接下来我们将看一下这些组件。

了解有关运动布局的更多信息:

#1 Nicolas Roard的运动布局系列简介

#2 James Pearson的高级和实用的运动布局演讲。

#3 运动布局上的Android官方开发人员指南

动画

所有动画放在一起,该项目的运动场景文件包含 10个 约束集9个 过渡 。以下视频演示了所有的ConstraintSets和Transitions,我们将寻找到 4个动画

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC

  1. 打开过滤器表:
    Set1→Set2→Set3→Set4
  2. 关闭滤纸:
    Set4→Set3→Set2→Set1
  3. 应用过滤器:
    Set4→Set5→Set6→Set7
  4. 删除过滤器:
    Set7→Set8→Set9→Set10

注意: 背景中的RecyclerView Items动画不是 运动布局 的一部分。在本文的后面,我们将看到如何 使用 运动布局 编排外部动画。

本文中的每个动画(GIF)都会在其下方显示约束集详细信息(Ex: Set 4, Transitioning…, Set 5, etc),以便在阅读和浏览源代码时更易于操作。

约束集是运动布局执行动画所需的 构造块 。你可以在此处指定所有约束、布局属性等。

一个 <ConstraintSet> 必须包含一个 <Constraint> 元素,并且该元素带有每个你想要将场景动画化的布局属性

分解你的元素

你可以在 <Constraint> 元素中指定所有布局属性。 但是 对于更复杂的动画,你应该使用 <Layout> <PropertySet> <Transform> <Motion> <CustomAttribute> 标签对其进行分解。

这允许你仅覆盖所需的属性,而无需重复写下所有属性。

app:deriveConstraintsFrom =“…”

deriveConstraintsFrom 是一个非常有用的标签,它允许你继承其他的 <ConstraintSet> 。这样,你不必重写所有视图/约束/属性,而只需重写要设置动画的视图/约束/属性就可以了。

将其与之前分解的 <Constraint> 元素结合起来,你将获得包含你想更改部分的简洁版约束集。

在该项目中,10个约束集中的每一个都从先前的集合派生而来,并且仅修改需要动画的内容。例如:在以下转换中,关闭图标旋转是通过从 Set5 中导出所有约束并仅在 Set6 中应用旋转来完成的。

1__PKoZ0I_4Aj2adGSteJUbg 1_BXjOwJORltd3n5o2cGqKJg

警告:覆盖 其中一个元素时,该元素中的所有属性都会被覆盖,因此你必须从该元素复制其他属性。

必要时展开你的视图

1_AuTuPGoxdr0YyejvJ733Dw

运动布局只能与它直接初始视图一起使用,并且没有嵌套视图。

例如,在此动画中,过滤器图标看起来像是圆形FAB( CardView )的一部分,但是它们被分成不同的视图,因为在动画中它们各自都有自己的工作。

同样,fab的高程从 Set1 → Set2 设置为动画,图标必须放在更高的位置才能显示。图标的这种不理想的效果是投射了自己的阴影。为了防止这种情况,我们可以使用:

android:outlineProvider="none"

阴影是由视图轮廓提供者创建的。如果将其设置为 none ,则不会创建阴影。

自定义属性

运动布局提供了我们要设置动画的大多数基本属性,但是它不能提供 一切 。例如,自定义视图可能需要设置其他属性的动画。

< CustomAttribute >通过允许你在视图中使用任何 设置器 来弥合这种差距。它使用反射来调用方法并设置值。

<CustomAttribute 
    app:attributeName =“ radius” 
    app:customDimension =“ 16dp” />

注意:你必须使用设置器名称,而不是xml attr名称。例如,CardView有一个 setRadius()法,而xml中的方法是 app:cardCornerRadius 。CustomAttribute应该引用设置器-“ radius”。

“隐形” vs“消失”

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC

设置从 invisible / gonevisible 的可见性动画时,需要留意这种差异。

gone → visible 将设置 Alpha 动画 并缩放

invisible → visible仅对alpha进行 动画处理。

<过渡/>

过渡是2个约束集之间的连接,它们指定了在其之间进行 转换 的开始和结束状态。

<Transition 
    app:constraintSetStart =“ @ id / set1” 
    app:constraintSetEnd =“ @ id / set2” 
    app:motionInterpolator =“ linear” 
    app:duration =“ 300” />

你还可以使用 <OnClick><OnSwipe> 元素在过渡中指定滑动和单击相关的功能,但是由于我们正在观察的10套动画不太需要 所以 我们将不在本文 中介绍。

插补器

我们可以使用 app:motionInterpolator 来为转换指定插值器。可用选项为 lineareaseIneaseOuteaseInOut 。当你把它们比作 AnticipateInterpolatorBounceInterpolator 等,那是不够的。

1_CvYt6_vlb-Drn6usAGHAeA
https://cubic-bezier.com/#0,1,.5,1

对于这些情况,你可以使用 cubic() 选项,在其中可以使用 贝塞尔曲线 定义自己的插值器。你可以创建自己的贝塞尔曲线,并在cube-bezier.com上获取值。

可以使用以下方法进行设置:

`app:motionInterpolator=”cubic(0,1,0.5,1)`

关键帧

有时,仅具有开始和结束状态是不够的。对于更复杂的动画,我们可能希望更详细地指定过渡的过程。关键帧可帮助我们在过渡中指定 “检查点”, 在该过渡中我们可以在任何给定时间更改视图的任一属性。

文章“运动布局中定义运动路径”深入探讨了关键帧及其使用方法。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC
:有关键帧… :无关键帧

左侧的动画具有 9个关键帧 ,而右侧的动画则没有关键帧。

如你所见,它们的开始(设置4)和结束(设置5)相同。但是,通过使用关键帧,我们可以更好地控制每个元素在过渡期间发生的变化。

结构化关键帧

每个 <Transition /> 都可以具有一个或多个 <KeyFrameSet /> 已经指定的所有关键帧的元素。对于此项目,仅用了 <KeyPosition /><KeyAttribute /> 元素。

1_GG0V6txfOjGmkWBvGgM-7w

  • motionTarget 指定哪个视图受关键帧影响。
  • framePosition 指定在过渡期间何时应用关键帧(0-100)
  • <KeyPosition /> 用于指定宽度、高度和x,y坐标的变化
  • <KeyAttribute /> 用于指定其他任何改动, 包括 CustomAttributes

framePosition = 0 vs 1

有时,我们想在动画的一 开始 就更改属性。在普通动画中,可以通过使用 animator.doOnStart{...} 或类似方法实现。让我们尝试通过关键帧实现相同的效果。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC
:framePosition = 1… :framePosition = 0

在此特定动画中,当用户单击“过滤器”按钮时,动画首先将fab(CardView)更改为一个圆形并缩小其大小。

这里的问题是,在动画开始时,何时将 framePosition = 0 用来更改值,运动布局不会记录它。

因此,如果你希望关键帧在任何过渡开始时都指定一些内容,请改用 framePosition = 1

<KeyAttribute 
    app:motionTarget =“ @ id / fab” 
    app:framePosition =“ 1”> 
    <CustomAttribute 
        app:attributeName =“ radius” 
        app:customDimension =“ 600dp” /> 
</ KeyAttribute>

必要时使用自定义视图

CustomAttributes 的可用性允许我们使用自定义视图灵活的进行布局。

例如,此动画中的许多过渡都涉及到FAB( CardView )来扩大和缩小 为一个圆形 。问题是,要将CardView保持为圆形 cornerRadius must be <= size/2 。通常情况下,使用类似 ValueAnimator 的方 法很容易,因为我们一直都知道所有值。

但是, 运动布局 使所有计算都远离了我们。因此,要实现这一点,我们必须引入一个新的视图:

1_TP1JjqYD_Xb0qaylEulQDQ

CircleCardView 通过将半径限制为最大size / 2来处理这种情况。现在,当 运动布局 调用设置器时(还记得 CustomAttributes 吗?),我们就不会遇到问题了。

编排多步动画

当前,运动布局没有允许受控的多步过渡API。我们可以使用 autoTransition ,但是有很大的局限性(我们将在后面讨论)。在伪代码中,以下是你要执行的操作:

// Transitioning from set1 -> set2 -> set3 -> set4
motionLayout.setTransition(set1, set2)
motionLayout.transitionToEnd()
motionLayout.doOnEnd {
    motionLayout.setTransition(set2, set3)
    motionLayout.transitionToEnd()
    motionLayout.doOnEnd {
        motionLayout.setTransition(set3, set4)
        motionLayout.transitionToEnd()
        motionLayout.doOnEnd {
            ...
        }
    }
}

这就很恶心了,又变成了可怕的回调地狱。 另一方面协程可以 帮助我们将异步回调代码转换为线性代码。

运动布局.awaitTransitionComplete()

克里斯·班纳斯(Chris Banes)撰写的有关“在视图暂停”的文章是有关如何在与视图相关的代码中实现协程的必读文章。

暂停视图 — 示例
A worked example from the Tivi app
medium.com

他向我们介绍了 awaitTransitionComplete() ,这是一个 暂挂函数 ,可隐藏所有侦听器,使你可以轻松地使用协程完成转换:

注意:所述的 awaitTransitionComplete() 扩展方法使用修饰 运动布局 ,它能设置多个侦听器而不是只有一个(要被设置功能的请求)。

自动转换

autoTransition 是在 没有协程时 实现多步过渡的最简单方法。假设我们要从 Set7 → Set8 → Set9 → Set10 实现 “ Removing Filters” 动画。

1_BzpK3fI5sfSSA_y4k4TiQw 1_6zdUCilxhMIf6xBvIaHXxg%20(1)

现在,如果这样做 运动布局 .transitionToState(set8) ,运动布局从 Set7 → Set8 开始过渡,到达 Set8 时,它会 自动转换Set9 ,并与 Set10 类似。

当运动布局在 constraintSetStart中达到指定的ConstraintSet时,autoTransition将自动执行过渡。

自动转换并不完美

如果再次观看动画,你会注意到有一个动画正在进行,适配器项在后台。为了与运动布局转换 并行 完成这些动画,我们必须使用协程,仅使用 autoTransition 不能正确设置它们的时间。

private fun unFilterAdapterItems(): Unit = lifecycleScope.launch {
  
  // 1) Set7 -> Set8 (Start scale down animation simultaneously)
  motionLayout.transitionToState(R.id.set8)
  startScaleDownAnimator(true) // Simulataneous
  motionLayout.awaitTransitionComplete(R.id.set8)
  
  // 2) Set8 -> Set9 (Un-filter adapter items simultaneously)
  (context as MainActivity).isAdapterFiltered = false // Simulataneous
  motionLayout.awaitTransitionComplete(R.id.set9)
  
  // 3) Set9 -> Set10 (Start scale 'up' animation simultaneously)
  startScaleDownAnimator(false) // Simulataneous
  motionLayout.awaitTransitionComplete(R.id.set10)
}

标有 //Simultaneous 的线与正在发生的过渡同时发生。

由于 autoTransition 不能从一个过渡到下一个,因此 awaitTransitionComplete() 仅在过渡完成时通知我们。它实际上 并不会 等到转换的结束。这就是为什么我们一开始只使用 transitionToState() 一次的原因。

多步前进和后退过渡

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC

自动转换与协同程序相结合可帮助我们实现对多步转换的控制。

但是,如果我们想在每次过渡时后退 Set4 → Set1 ,该怎么办?

例如,反转 特定的 过渡。使用 transitionToStart() 完成 Set4 → Set3 。如果我们使用 autoTransition ,那么就会因为autoTransition而自动动画到 Set3 ,然后再自动回到 Set4

打开动画

由于未使用 autoTransition ,因此打开过滤器表的代码与上一节中看到的代码略有不同。

/** Order of animation: Set1 -> Set2 -> Set3 -> Set4 */
private fun openSheet(): Unit = lifecycleScope.launch {
  
  // Set the start transition. This is necessary because the
  // un-filtering animation ends with set10 and we need to
  // reset it here when opening the sheet the next time
  motionLayout.setTransition(R.id.set1, R.id.set2)
  
  // 1) Set1 -> Set2 (Start scale down animation simultaneously)
  motionLayout.transitionToState(R.id.set2)
  startScaleDownAnimator(true) // Simultaneous
  motionLayout.awaitTransitionComplete(R.id.set2)
  
  // 2) Set2 -> Set3
  motionLayout.transitionToState(R.id.set3)
  motionLayout.awaitTransitionComplete(R.id.set3)
  
  // 3) Set3 -> Set4
  motionLayout.transitionToState(R.id.set4)
  motionLayout.awaitTransitionComplete(R.id.set4)
}
  • 每次等待后我们都必须使用 transitionToState() 。之前没有必要这样做,因为 autoTransition 无需等待就可以经过所有对象。而在这里,我们必须手动进行。
  • 注意,等待之后我们不会每次都使用 setTransition() 。这是因为 运动布局 将根据 transitionToState() 中提到的当前约束集和ConstraintSet来标识要使用的转换。

收盘动画(反向)

/** Order of animation: Set4 -> Set3 -> Set2 -> Set1 */
private fun closeSheet(): Unit = lifecycleScope.launch {
  
  // We don't have to setTransition() here since current transition is Set3 -> Set4.
  // transitionToStart() will automatically go from:
  // 1) Set4 -> Set3
  motionLayout.transitionToStart()
  motionLayout.awaitTransitionComplete(R.id.set3)
    
  // 2) Set3 -> Set2
  motionLayout.setTransition(R.id.set2, R.id.set3)
  motionLayout.progress = 1f
  motionLayout.transitionToStart()
  motionLayout.awaitTransitionComplete(R.id.set2)
  
  // 3) Set2 -> Set1 (Start scale 'up' animator simultaneously)
  motionLayout.setTransition(R.id.set1, R.id.set2)
  motionLayout.progress = 1f
  motionLayout.transitionToStart()
  startScaleDownAnimator(false) // Simultaneous
  motionLayout.awaitTransitionComplete(R.id.set1)
}

由于所有 <Transition> 元素都是基于正向的,因此我们必须添加几行代码使其可逆。它的本质是:

// Set the transition to be reversed (MotionLayout can only detect forward transitions).
motionLayout.setTransition(startSet, endSet)
// This will set the progress of the transition to the end
motionLayout.progress = 1f
// Reverse the transition from end to start
motionLayout.transitionToStart()
// Wait for transition to reach the start
motionLayout.awaitTransitionComplete(startSet)
// Repeat for every transition...

:heavy_check_mark:现在,这使我们能够反向完成多个转换,同时保持并执行其他操作的能力。

结论—是否使用运动布局?

运动布局 与协程结合使用,可以非常轻松地用很少的代码来实现非常复杂的动画, 同时保持平面视图层次!

运动布局十分简洁,并为我们提供了很多有用的东西。使用协程和即将推出的IDE编辑器,其前途无可限量。

希望你会喜欢这篇文章:smiley:!感兴趣的话,请查看源代码!!

原文作者 Nikhil Panju
原文链接 https://proandroiddev.com/complex-ui-animations-on-android-featuring-motionlayout-aa82d83b8660

推荐阅读
相关专栏
开发者实践
186 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。