使用 Kotlin 03.2 进行高级 Android 开发:使用 MotionLayout 的动画效果

此 Codelab 是“使用 Kotlin 进行高级 Android 开发”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘课程的价值,但并不强制要求这样做。“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页列出了所有课程 Codelab。

MotionLayout 是一个库,借助它,您可以向 Android 应用添加丰富的运动效果。它基于 ConstraintLayout,,可让您使用 ConstraintLayout 为您可以构建的任何对象添加动画效果。

您可以使用 MotionLayout 同时为多个视图的位置、大小、可见性、alpha 值、颜色、高度、旋转度以及其他属性添加动画效果。使用声明性 XML,您可以创建涉及多个视图的协调动画,而这难以通过代码实现。

添加动画是改善应用体验的绝佳方式。您可以将动画用于以下用途:

  • 显示变化 - 在不同状态之间添加动画,可让用户自然而然地跟踪界面中的变化情况。
  • 吸引用户注意 - 使用动画吸引用户注意重要的界面元素。
  • 构建精美的设计 - 在设计中添加有效的运动效果可让应用看起来更精美。

前提条件

此 Codelab 专为具备一定 Android 开发经验的开发者而设计。在尝试完成此 Codelab 之前,您应该具备以下条件:

  • 了解如何使用 Android Studio 打造包含 activity 和基本布局的应用,以及如何在设备或模拟器上运行该应用。熟悉 ConstraintLayout。如需详细了解 ConstraintLayout,请仔细阅读约束布局 Codelab

您应执行的操作

  • 使用 ConstraintSetsMotionLayout 定义动画效果
  • 根据拖动事件添加动画
  • 使用 KeyPosition 更改动画
  • 使用 KeyAttribute 更改属性
  • 使用代码运行动画
  • 使用 MotionLayout 为可收起的标头添加动画

所需条件

  • Android Studio 4.0MotionLayout 编辑器仅适用于该版本的 Android Studio。)

若要下载示例应用,您可以执行以下操作之一:

下载 Zip 文件

…或从命令行使用下列命令克隆 GitHub 代码库:

$ git clone https://github.com/googlecodelabs/motionlayout.git

首先,您将构建一个动画以响应用户点击,从而将视图从屏幕顶端的起始位置移到屏幕底端的结束位置。

若要通过起始代码创建动画,您需要以下几项主要元素:

  • MotionLayout,,它是 ConstraintLayout 的子类。您要在 MotionLayout 标记中指定要添加动画的所有视图。
  • MotionScene,,它是用于一个描述 MotionLayout. 动画的 XML 文件。
  • Transition,MotionScene 的一部分,用于指定动画的时长、触发因素以及如何移动视图。
  • ConstraintSet,用于指定过渡的起始结束约束条件。

我们将逐个了解一下每个元素,首先从 MotionLayout 开始。

第 1 步:探索现有代码

MotionLayoutConstraintLayout 的子类,因此在添加动画时,后者的所有功能它全都支持。若要使用 MotionLayout,请在需要使用 ConstraintLayout. 的位置添加 MotionLayout 视图。

  1. res/layout 中,打开 activity_step1.xml.
    此时,您会得到一个 ConstraintLayout(其中包含一个星形图案的单个 ImageView),并且其内部应用了色调。
<!-- initial code -->
<!-- activity_step1.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
       ...
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       >

   <ImageView
           android:id="@+id/red_star"
           ...
   />

</androidx.constraintlayout.motion.widget.MotionLayout>

ConstraintLayout 对其没有任何约束条件,因此,如果您运行应用,您现在会看到星形图案在不受任何约束的情况下显示。Android Studio 将向您发出警告,以说明缺少限制条件。

第 2 步:转换为动态布局

若要使用 MotionLayout, 添加动画,您必须将 ConstraintLayout 转换为 MotionLayout

运动场景是一个单个 XML 文件,用于描述 MotionLayout 中的动画。

若要让布局使用运动场景,布局必须指向相应场景。

  1. 为此,请打开设计图面。在 Android Studio 4.0 中,您可以在查看布局 XML 文件时使用右上角的图标打开设计图面。

  1. 打开设计图面后,右键点击预览,然后选择 Convert to MotionLayout

这会将 ConstraintLayout 标记替换为 MotionLayout 标记,并添加指向 @xml/activity_step1_scene.app:layoutDescription

<!-- activity_step1.xml →
<!-- add app:layoutDescription="@xml/step1" -->

<androidx.constraintlayout.motion.widget.MotionLayout
       ...
       app:layoutDescription="@xml/activity_step1_scene">

第 3 步:定义起始和结束约束条件

所有动画都可以根据起始和结束进行定义。起始用于描述动画开始前屏幕的显示效果,结束用于描述动画完成后屏幕的显示效果。MotionLayout 负责确定如何在起始状态和结束状态(随时间变化)之间添加动画。

MotionScene 使用 ConstraintSet 标记来定义起始状态和结束状态。顾名思义,ConstraintSet 就是可应用于视图的约束条件集。其中包括宽度、高度和 ConstraintLayout 约束条件。此外,它还包含一些属性,例如 alpha。它不包含视图本身,而只包含这些视图的约束条件。

ConstraintSet 中指定的任何约束条件都将替换在布局文件中指定的约束条件。如果您在布局和 MotionScene 中都定义了约束条件,系统仅会应用 MotionScene 中的约束条件。

在这一步,您要限制星形视图,使其在屏幕顶部的起始位置开始,并在屏幕底部的结束位置结束。

  1. MotionScene 中,将 @id/start 的 TODO 替换为以下 ConstraintSet
<!-- xml/activity_step1_scene.xml -->
<!-- TODO: Define @id/start -->

<!-- Constraints to apply at the start of the animation -->
<ConstraintSet android:id="@+id/start">
   <Constraint
           android:id="@+id/red_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>

ConstraintSetid@id/start,它会指定要应用于 MotionLayout 中的所有视图的所有约束条件。由于这个 MotionLayout 只有一个视图,因此它只需要一个 Constraint

ConstraintSet 内的 Constraint 会指定要限制的视图的 ID,即 activity_step1.xml 中定义的 @id/red_star。请务必注意,Constraint 标记仅会指定约束条件和布局信息。Constraint 标记并不知道自己将应用于 ImageView

该约束条件会指定高度、宽度以及将 red_star 约束到其父级顶部的起始位置所需的两个其他约束条件。

  1. 接下来,在 MotionScene 中为星形动画的结束指定 ConstraintSet
<!-- xml/activity_step1_scene.xml →
<!-- TODO: Define @id/end -->

<!-- Constraints to apply at the end of the animation -->
<ConstraintSet android:id="@+id/end">
   <Constraint
           android:id="@+id/red_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintBottom_toBottomOf="parent" />
</ConstraintSet>

@id/start 类似,这个 ConstraintSet@id/red_star 上也有一个 Constraint。这次,它会把它约束在屏幕底部的结束位置。

您不一定要将其命名为 @id/start@id/end,但这样命名很方便。

第 4 步:定义过渡

每个 MotionScene 还必须包含至少一个过渡。过渡定义了动画从起始到结束的所有环节。

过渡必须指定过渡的起始和结束 ConstraintSet。此外,过渡还可以指定如何以其他方式修改动画,例如运行动画的时长,或如何通过拖动视图来添加动画。

  1. MotionScene 中,替换 TODO 以定义过渡。
<!-- xml/activity_step1_scene.xml -->
<!-- TODO: Define a Transition -->

<!-- A transition describes an animation via start and end state -->
<Transition
    app:constraintSetStart="@+id/start"
    app:constraintSetEnd="@+id/end"
    app:duration="2000">
    <!-- TODO: Handle clicks -->
</Transition>

这就是 MotionLayout 构建动画所需的所有元素。查看每个属性:

  • 动画开始时,constraintSetStart 将应用于视图。
  • 动画结束时,constraintSetEnd 将应用于视图。
  • duration 以毫秒为单位指定动画应持续的时间。

然后,MotionLayout 会确定起始和结束约束条件之间的路径,并为其添加指定时长的动画。

您需要一种方法来启动动画。其中一种方法是让 MotionLayout 响应 @id/red_star 的点击事件。

  1. 若要让 MotionLayout 响应点击事件,请将过渡更新为包含 OnClick 标记。
<!-- xml/activity_step1_scene.xml -->
<!-- TODO: Handle clicks -->

<!-- A transition describes an animation via start and end state -->
<Transition
    app:constraintSetStart="@+id/start"
    app:constraintSetEnd="@+id/end"
    app:duration="2000">
    <!-- MotionLayout will handle clicks on @id/red_star to "toggle" the animation between the start and end -->
    <OnClick
        app:targetId="@id/red_star"
        app:clickAction="toggle" />
</Transition>

Transition 使用 <OnClick> 标记指示 MotionLayout 运行动画以响应点击事件。查看每个属性:

  • targetId 是用于监视点击的视图。
  • 在点击鼠标时,toggleclickAction 会在起始和结束状态之间进行切换。您可以在相关文档中查看 clickAction 的其他选项。
  1. 运行您的代码,点击第 1 步,然后点击红色星形图案,查看一下动画效果吧!

第 5 步:动画的实际运用

运行应用!点击星形图案后,您应该会看到动画运行的画面。

完成的动画场景文件定义了一个 Transition,它指向一个起始和结束 ConstraintSet

在动画启动时 (@id/start),系统会将星形图标约束到屏幕顶部的起始位置。在动画结束时 (@id/end),系统会将星形图标约束到屏幕底部的结束位置。

<?xml version="1.0" encoding="utf-8"?>

<!-- Describe the animation for activity_step1.xml -->
<MotionScene xmlns:app="http://schemas.android.com/apk/res-auto"
            xmlns:android="http://schemas.android.com/apk/res/android">
   <!-- A transition describes an animation via start and end state -->
   <Transition
           app:constraintSetStart="@+id/start"
           app:constraintSetEnd="@+id/end"
           app:duration="2000">
       <!-- MotionLayout will handle clicks on @id/star to "toggle" the animation between the start and end -->
       <OnClick
               app:targetId="@id/red_star"
               app:clickAction="toggle" />
   </Transition>

   <!-- Constraints to apply at the end of the animation -->
   <ConstraintSet android:id="@+id/start">
       <Constraint
               android:id="@+id/red_star"
               android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               app:layout_constraintStart_toStartOf="parent"
               app:layout_constraintTop_toTopOf="parent" />
   </ConstraintSet>

   <!-- Constraints to apply at the end of the animation -->
   <ConstraintSet android:id="@+id/end">
       <Constraint
               android:id="@+id/red_star"
               android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               app:layout_constraintEnd_toEndOf="parent"
               app:layout_constraintBottom_toBottomOf="parent" />
   </ConstraintSet>
</MotionScene>

在这一步,您将构建一个响应用户拖动事件(当用户滑动屏幕时)的动画,以便运行该动画。MotionLayout 支持跟踪触摸事件以移动视图,也支持跟踪基于物理特性的滑动手势以让运动更加流畅。

第 1 步:检查起始代码

  1. 首先,打开布局文件 activity_step2.xml,其中已有 MotionLayout。查看代码。
<!-- initial code in activity_step2.xml -->

<androidx.constraintlayout.motion.widget.MotionLayout
       ...
       app:layoutDescription="@xml/step2" >

   <ImageView
           android:id="@+id/left_star"
           ...
   />

   <ImageView
           android:id="@+id/right_star"
           ...
   />

   <ImageView
           android:id="@+id/red_star"
           ...
   />

   <TextView
           android:id="@+id/credits"
           ...
           app:layout_constraintTop_toTopOf="parent"
           app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.motion.widget.MotionLayout>

这个布局定义了动画的所有视图。3 个星形图标在布局中未受任何约束,因为它们将在运动场景中以动画方式显示。

我们确实对所有者信息 TextView 应用了约束条件,这是因为它在整个动画中的位置始终保持不变,并且不会修改任何属性。

第 2 步:为场景添加动画

与上一个动画一样,该动画将由起始和结束 ConstraintSet, 以及 Transition 定义。

定义起始 ConstraintSet

  1. 打开运动场景 xml/step2.xml 以定义动画。
  2. 为起始约束条件 start 添加约束条件。最开始,3 个星形图案均在屏幕底部居中显示。右侧和左侧星形图案的 alpha 值为 0.0,这意味着它们完全透明并处于隐藏状态。
<!-- xml/step2.xml →
<!-- TODO apply starting constraints -->

<!-- Constraints to apply at the start of the animation -->
<ConstraintSet android:id="@+id/start">
   <Constraint
           android:id="@+id/red_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintBottom_toBottomOf="parent" />

   <Constraint
           android:id="@+id/left_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:alpha="0.0"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintBottom_toBottomOf="parent" />

   <Constraint
           android:id="@+id/right_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:alpha="0.0"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintBottom_toBottomOf="parent" />
</ConstraintSet>

在此 ConstraintSet 中,您需为每个星形图案分别指定一个 ConstraintMotionLayout 会在动画启动后应用各项约束条件。

我们已使用起始、结束和底部约束条件让每个星形图案视图都在屏幕底部居中。@id/left_star@id/right_star 这两个星形图案都有一个额外的 alpha 值,该值可让它们处于不可见状态,并会在动画启动时应用。

startend 约束条件集定义了动画的起始和结束。起始时的约束条件(如 app:layout_constraintStart_toStartOf)会将一个视图的起始位于约束到另一个视图的起始位置。乍一看,这可能会令人困惑,因为二者都用到了 start 这个名称,并且二者都用于约束条件的上下文。为便于区分,我们规定 layout_constraintStart 中的 start 是指视图的“起始位置”。在从左到右书写的语言中,该位置位于左侧;在从右向左书写的语言中,该位置位于右侧。start 约束条件集是指动画的起始位置。

定义结束 ConstraintSet

  1. 将结束约束条件定义为使用将 3 个星形图案全部放置在 @id/credits 下。
    此外,它还会将左侧和右侧星形图案的 alpha 的结束值设置为 1.0
<!-- xml/step2.xml →
<!-- TODO apply ending constraints →

<!-- Constraints to apply at the end of the animation -->
<ConstraintSet android:id="@+id/end">

   <Constraint
           android:id="@+id/left_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:alpha="1.0"
           app:layout_constraintHorizontal_chainStyle="packed"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintEnd_toStartOf="@id/red_star"
           app:layout_constraintTop_toBottomOf="@id/credits" />

   <Constraint
           android:id="@+id/red_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:layout_constraintStart_toEndOf="@id/left_star"
           app:layout_constraintEnd_toStartOf="@id/right_star"
           app:layout_constraintTop_toBottomOf="@id/credits" />

   <Constraint
           android:id="@+id/right_star"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:alpha="1.0"
           app:layout_constraintStart_toEndOf="@id/red_star"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintTop_toBottomOf="@id/credits" />
</ConstraintSet>

最终结果是,当以动画方式显示时,这些视图会从中心向外扩展并向上移动。

此外,由于两个 ConstraintSets 中的 @id/right_start@id/left_star 上都设置了 alpha 属性,因此随着动画的播放,这两个视图会淡入。

根据用户滑动添加动画

MotionLayout 可以跟踪用户拖动事件(或滑动),从而创建基于物理特性的“快速滑动”动画效果。这意味着,如果用户快速滑动视图,视图将会保持移动状态,并像实物在表面上滚动一样降低移动速度。您可以在 Transition 中使用 OnSwipe 标记来添加此类动画。

  1. 将用于添加 OnSwipe 标记的 TODO 替换为 <OnSwipe app:touchAnchorId="@id/red_star" />
<!-- xml/step2.xml -->
<!-- TODO add OnSwipe tag -->

<!-- A transition describes an animation via start and end state -->
<Transition
       app:constraintSetStart="@+id/start"
       app:constraintSetEnd="@+id/end">
   <!-- MotionLayout will track swipes relative to this view -->
   <OnSwipe app:touchAnchorId="@id/red_star" />
</Transition>

OnSwipe 包含一些属性,其中最重要的属性是 touchAnchorId

  • touchAnchorId 是所跟踪的视图,它会通过移动来响应轻触操作。MotionLayout 会将该视图保持在与快速滑动的手指相同的距离。
  • touchAnchorSide 用于确定应跟踪视图的哪一侧。对于要调整大小、遵循复杂路径或一侧的移动速度比另一侧快的视图来说,这非常重要。
  • dragDirection 用于确定可对该动画效果产生影响的方向(上、下、左或右)。

MotionLayout 监听拖动事件时,系统将在 MotionLayout 视图(而不是由 touchAnchorId 指定的视图)上注册相应监听器。当用户在屏幕上的任意位置开始某项手势操作时,MotionLayout 会让用户的手指与 touchAnchorId 视图的 touchAnchorSide 之间的距离保持恒定。例如,如果用户轻触的位置与锚定侧的距离为 100dp,那么会在整个动画播放期间,MotionLayout 都会让这一侧与用户的手指保持 100dp 的距离。

试试看

  1. 再次运行该应用,然后打开第 2 步对应的屏幕。您会看到相应的动画效果。
  2. 试着在动画播放过程中“快速滑动”或松开手指,了解 MotionLayout 如何显示流畅的基于物理特性的动画!

MotionLayout 可以使用 ConstraintLayout 中的功能在截然不同的设计之间添加动画,以便打造丰富的效果。

在该动画中,3 个视图在起始时都以其父级为参照物,定位在屏幕底部。结束时,3 个视图是以链中的 @id/credits 为参照物进行定位的。

尽管布局截然不同,但 MotionLayout 会在起始位置和结束位置之间创建流畅的动画。

在这一步,您将构建一个动画,使其在动画播放期间遵循复杂的路径,并在运动期间让所有者信息以动画方式显示。MotionLayout 可以使用 KeyPosition 修改视图在起始位置和结束位置之间采用的路径。

第 1 步:探索现有代码

  1. 打开 layout/activity_step3.xmlxml/step3.xml,查看现有的布局和运动场景。
    ImageViewTextView 分别用于显示月亮和所有者信息文字。
  2. 打开运动场景文件 (xml/step3.xml)。
    此时,您会发现我们已经定义了一个从 @id/start@id/endTransition。该动画使用两个 ConstraintSets 将月亮图片从屏幕左下角移到屏幕右下角。随着月亮的移动,所有者信息会从 alpha="0.0" 淡入到 alpha="1.0"
  3. 立即运行该应用,然后选择第 3 步
    此时您会发现,如果您点击月亮,它会沿一条线性路径(直线)从起始位置移到结束位置。

第 2 步:启用路径调试功能

在将弧形路径添加到月球的运动之前,最好在 MotionLayout 中启用路径调试功能。

为了帮助您使用 MotionLayout 开发复杂的动画,您可以绘制每个视图的动画路径。如果您要直观呈现动画,以及对运动的小细节进行微调的话,这会很有用。

  1. 如需启用调试路径功能,请打开 layout/activity_step3.xml,然后将 app:motionDebug="SHOW_PATH" 添加到 MotionLayout 标记。
<!-- layout/activity_step3.xml -->
<!-- Add app:motionDebug="SHOW_PATH" -->

<androidx.constraintlayout.motion.widget.MotionLayout
       ...
       app:motionDebug="SHOW_PATH" >

启用路径调试功能后,如果您再次运行该应用,就会发现系统能够使用虚线直观呈现所有视图的路径。

  • 圆圈代表视图的起始位置或结束位置。
  • 线代表视图的路径。
  • 菱形代表用于修改路径的 KeyPosition

例如,在该动画中,中间圆圈代表所有者信息文字的位置。

第 3 步:修改路径

MotionLayout 中的所有动画均由起始和结束 ConstraintSet 定义,后者用于定义在动画启动前和动画结束后屏幕的外观。默认情况下,MotionLayout 会在每个改变位置的视图的起始位置和结束位置之间绘制一条线性路径(直线)。

如需构建复杂路径(如该示例中的月亮),MotionLayout 会使用 KeyPosition 来修改视图的起始位置和结束位置之间的路径。

  1. 打开 xml/step3.xml 并向场景添加 KeyPositionKeyPosition 标记位于 Transition 标记内。

<!-- xml/step3.xml -->
<!-- TODO: Add KeyFrameSet and KeyPosition -->
<KeyFrameSet>
   <KeyPosition
           app:framePosition="50"
           app:motionTarget="@id/moon"
           app:keyPositionType="parentRelative"
           app:percentY="0.5"
   />
</KeyFrameSet>

KeyFrameSetTransition 的子级,也是在过渡期间应该应用的所有 KeyFrames(例如 KeyPosition)的集合。

由于 MotionLayout 会计算月亮在起始位置和结束位置之间的路径,因此它会根据 KeyFrameSet 中指定的 KeyPosition 来修改路径。您可以再次运行该应用,以查看这项操作会如何修改路径。

KeyPosition 有多个属性,用于描述它会如何修改路径。其中最重要的属性如下:

  • framePosition 是一个介于 0 到 100 之间的数字。它定义了在动画中应用该 KeyPosition 的时间,其中 1 代表动画播放到 1% 的位置,99 代表动画播放到 99% 的位置。因此,如果该值为 50,则表示您要在动画播放到正中间时应用它。
  • motionTarget 是被该 KeyPosition 修改路径的视图。
  • keyPositionType 是该 KeyPosition 修改路径的方式,它可以是 parentRelativepathRelativedeltaRelative(如下一步所述)。
  • percentX | percentY 是指在 framePosition 按多大百分比来修改路径(值介于 0.0 到 1.0 之间,允许使用负数值和大于 1 的值)。

您可以这样理解它:“在 framePosition 修改 motionTarget 的路径,方法是根据 keyPositionType 确定的坐标,将路径移动 percentXpercentY。”

默认情况下,MotionLayout 会将因修改路径而产生的所有角设为圆角。如果您查看刚刚创建的动画,就会发现月亮会沿着弯曲的曲线路径移动。对于大多数动画来说,这正是您需要的效果;否则,您可以指定 curveFit 属性来对其进行自定义。

试试看

如果您再次运行该应用,就会看到这一步对应的动画效果。

月亮会沿弧线移动,因为它要经过 Transition 中指定的 KeyPosition

<KeyPosition
       app:framePosition="50"
       app:motionTarget="@id/moon"
       app:keyPositionType="parentRelative"
       app:percentY="0.5"
/>

您可以将以上 KeyPosition 解读为:“在 framePosition 50(动画播放到一半的位置)修改 motionTarget @id/moon 的路径,方法是根据 parentRelative 确定的坐标(整个 MotionLayout),将路径移动 50% Y(在屏幕中间向下移动)”

因此,在动画播放到一半的位置时,月亮必须经过一个位于屏幕高度 50% 处的 KeyPosition。该 KeyPosition 没有对 X 轴的运动做任何修改,因此,月亮在水平方向上仍会从起始位置移到结束位置。MotionLayout 会确定一条流畅的路径,这个路径会在从起始位置移到结束位置的过程中经过该 KeyPosition

如果您仔细观察,就会发现所有者信息文字受月亮位置的约束。为什么它不在垂直方向上一起移动呢?

<Constraint
       android:id="@id/credits"
       ...
       app:layout_constraintBottom_toBottomOf="@id/moon"
       app:layout_constraintTop_toTopOf="@id/moon"
/>

实际上,即使您修改了月亮采用的路径,月亮的起始位置和结束位置也根本不会在垂直方向上移动它。KeyPosition 不会修改起始位置或结束位置,因此系统会将所有者信息文字约束到月亮的最终结束位置。

如果您要让所有者信息与月亮一起移动,可以向所有者信息添加 KeyPosition,或修改 @id/credits 的约束条件。

在下一部分中,您将深入了解 MotionLayout 中不同类型的 keyPositionType

在上一步,您使用 parentRelativekeyPosition 类型将路径偏移了屏幕的 50%。keyPositionType 属性用于确定 MotionLayout 将如何根据 percentXpercentY 来修改路径。

<KeyFrameSet>
   <KeyPosition
           app:framePosition="50"
           app:motionTarget="@id/moon"
           app:keyPositionType="parentRelative"
           app:percentY="0.5"
   />
</KeyFrameSet>

您可能会用到 3 种不同类型的 keyPositionparentRelativepathRelativedeltaRelative。指定类型将会更改用于计算 percentXpercentY 的坐标系。

什么是坐标系?

坐标系提供了用于在空间中指定一个点的位置的方法。此外,坐标系也可用于描述屏幕上的位置。

MotionLayout 坐标系属于笛卡尔坐标系。这意味着,它们具有由两条互相垂直的直线定义的 X 轴和 Y 轴。它们的主要区别在于 X 轴在屏幕上的位置(Y 轴始终与 X 轴垂直)。

MotionLayout 中的所有坐标系在 X 轴和 Y 轴上使用的值都介于 0.01.0 之间。它们允许使用负值和大于 1.0 的值。例如,如果 percentX 的值为 -2.0,则表示沿 X 轴的反方向移动两次。

如果以上内容听起来有点太像代数课,请查看以下图片!

parentRelative 坐标

parentRelativekeyPositionType 使用与屏幕相同的坐标系。它会将 (0, 0) 定义为整个 MotionLayout 的左上角,并将 (1, 1) 定义为右下角。

您可以随时使用 parentRelative 来制作在整个 MotionLayout 中移动的动画,例如该示例中的月弧。

不过,如果您要相对于运动来修改路径(例如,让路径略微弯曲),则最好使用另外两个坐标系。

deltaRelative 坐标

增量是一个关于变化的数学术语,因此 deltaRelative 是表示“相对变化”的一种方式。在 deltaRelative 坐标中,(0,0) 是视图的起始位置,(1,1) 是结束位置。X 轴和 Y 轴与屏幕对齐。

X 轴在屏幕上始终是水平的,而 Y 轴在屏幕上始终是垂直的。与 parentRelative 相比,主要区别在于此类坐标仅描述视图移动将要经过的部分屏幕。

deltaRelative 是一种非常适合用于单独控制水平或垂直运动的坐标系。例如,您可以创建一个动画,使其在垂直 (Y) 方向上仅完成 50% 的移动,然后在水平方向 (X) 上继续以动画方式显示。

pathRelative 坐标

MotionLayout 中的最后一个坐标系是 pathRelative。它与前两个坐标系截然不同,因为 X 轴会遵循从起始位置到结束位置的动画路径。因此,(0,0) 是起始位置,(1,0) 是结束位置。

为什么要使用这个坐标系?乍一看很奇怪,尤其是因为该坐标系甚至未与屏幕坐标系对齐。

实际上,pathRelative 在以下几个方面非常有用。

  • 在部分动画内容播放期间让某个视图加速、减速或停止。由于 X 维度将始终与视图采用的路径保持完全一致,因此,您可以使用 pathRelative KeyPosition 来更改相应路径中的特定点所达到的 framePosition。因此,如果 framePosition="50"KeyPosition 且采用 percentX="0.1",则会让动画使用 50% 的时间来传输动画的前 10% 的内容。
  • 向路径添加细微的弧线。由于 Y 轴始终与运动垂直,因此如果更改 Y 轴,就会将路径更改为曲线(相对于整体运动)。
  • deltaRelative 不起作用的情况下添加第二个维度。对于完全水平和垂直的运动,deltaRelative 只会创建一个有用的维度。不过,pathRelative 始终会创建可用的 X 和 Y 坐标。

在下一步,您将学习如何使用多个 KeyPosition 构建更复杂的路径。

看一下您在上一步中构建的动画,它确实能创建一个流畅的曲线,但曲线的形状还可以“更像月亮”。

使用多个 KeyPosition 元素修改路径

MotionLayout 可以根据需要定义任意数量的 KeyPosition 以进一步修改路径,从而实现任何运动。对于该动画,您将构建一个弧线,但如果您愿意,您可以让月亮在屏幕中间上下挑动。

  1. 打开 xml/step4.xml。您会发现,它的视图和 KeyFrame 与您在上一步添加的一样。
  2. 若要将曲线顶部变圆,可以在 @id/moon 的路径中再添加两个 KeyPositions,一个添加到路径到达顶部之前,一个添加到路径到达顶部之后。

<!-- xml/step4.xml -->
<!-- TODO: Add two more KeyPositions to the KeyFrameSet here -->
<KeyPosition
       app:framePosition="25"
       app:motionTarget="@id/moon"
       app:keyPositionType="parentRelative"
       app:percentY="0.6"
/>
<KeyPosition
       app:framePosition="75"
       app:motionTarget="@id/moon"
       app:keyPositionType="parentRelative"
       app:percentY="0.6"
/>

系统会在动画播放到 25% 和 75% 的位置时分别应用这两个 KeyPositions,并让 @id/moon 经过距屏幕顶部 60% 的路径。结合现有的 50% 的 KeyPosition,即可创建一条流畅的弧线,并让月亮沿该弧线移动。

MotionLayout 中,您可以根据需要添加任意数量的 KeyPositions,以实现所需的动画路径。MotionLayout 会在指定的 framePosition 上应用每个 KeyPosition,并确定如何创建经过所有 KeyPositions 的流畅的运动。

试试看

  1. 再次运行该应用。转到第 4 步,查看动画的实际效果。
    如果您点击月亮,它会按照从起始位置到结束位置的路径移动,并经过 KeyFrameSet 中指定的每个 KeyPosition

自行探索

在学习其他类型的 KeyFrame 之前,请尝试向 KeyFrameSet 中添加更多 KeyPositions,看看单纯使用 KeyPosition 可以创建出什么样的效果。

以下示例说明了如何构建在动画播放期间能够来回移动的复杂路径。

<!-- Complex paths example: Dancing moon -->
<KeyFrameSet>
   <KeyPosition
           app:framePosition="25"
           app:motionTarget="@id/moon"
           app:keyPositionType="parentRelative"
           app:percentY="0.6"
           app:percentX="0.1"
   />
   <KeyPosition
           app:framePosition="50"
           app:motionTarget="@id/moon"
           app:keyPositionType="parentRelative"
           app:percentY="0.5"
           app:percentX="0.3"
   />
   <KeyPosition
           app:framePosition="75"
           app:motionTarget="@id/moon"
           app:keyPositionType="parentRelative"
           app:percentY="0.6"
           app:percentX="0.1"
   />
</KeyFrameSet>

探索完 KeyPosition 之后,您将在下一步中学习其他类型的 KeyFrames

构建动态动画通常意味着,要随着动画的播放不断更改视图的 sizerotationalphaMotionLayout 支持使用 KeyAttribute 为任意视图上的许多属性添加动画。

在这一步,您将使用 KeyAttribute 让月亮进行缩放和旋转。此外,您还将使用 KeyAttribute 将文字的显示时间延迟到月亮的移动过程接近完成时。

第 1 步:使用 KeyAttribute 调整大小和进行旋转

  1. 打开 xml/step5.xml,其中包含的动画与您在上一步中构建的相同。
    为了换换口味,该屏幕使用其他太空图片作为背景。
  2. 为了让月亮实现放大和旋转,请在 KeyFrameSet 中的 keyFrame="50"keyFrame="100" 处添加两个 KeyAttribute 标记。

<!-- xml/step5.xml -->

<!-- TODO: Add KeyAttributes to rotate and resize @id/moon -->

<KeyAttribute
       app:framePosition="50"
       app:motionTarget="@id/moon"
       android:scaleY="2.0"
       android:scaleX="2.0"
       android:rotation="-360"
/>
<KeyAttribute
       app:framePosition="100"
       app:motionTarget="@id/moon"
       android:rotation="-720"
/>

这两个 KeyAttributes 分别应用于动画播放到 50% 和 100% 的位置。第一个 KeyAttribute 应用于动画播放到 50% 的位置,它将发生在弧线的顶部,并让视图放大一倍,同时旋转 -360 度(或一整圈)。第二个 KeyAttribute 会完成第二次旋转(角度为 -720 度,即两整圈),并将视图缩小为正常大小,因为 scaleXscaleY 的值默认为 1.0。

KeyPosition 一样,KeyAttribute 使用 framePositionmotionTarget 来指定何时应用 KeyFrame 以及要修改哪个视图。MotionLayout 会在 KeyPositions 之间插值,以创建流畅的动画。

KeyAttributes 支持可应用于所有视图的属性。它们支持更改 visibilityalphaelevation 等基本属性。此外,您还可以像完成以上设置时一样更改旋转角度,使用 rotateXrotateY 让视图在 3 个维度上旋转,使用 scaleXscaleY 缩放视图的大小,或平移视图在 X 轴、Y 轴 或 Z 轴上的位置。

第 2 步:延迟所有者信息的显示时间

这一步的目的之一是更新动画,让所有者信息文字在动画快播放完毕时再显示。

  1. 若要延迟所有者信息的显示时间,请再定义一个 KeyAttribute,用于确保 alphakeyPosition="85" 之前一直为 0。
    MotionLayout 的 alpha 值仍会顺畅地从 0 过渡到 100,但会在动画的最后 15% 的内容中进行过渡。
<!-- xml/step5.xml -->

<!-- TODO: Add KeyAttribute to delay the appearance of @id/credits -->

<KeyAttribute
       app:framePosition="85"
       app:motionTarget="@id/credits"
       android:alpha="0.0"
/>

KeyAttribute 可让 @id/creditsalpha 在动画的前 85% 的内容中一直为 0.0。由于它的起始 alpha 值为 0,因此这意味着它在动画的前 85% 的内容中是不可见的。

KeyAttribute 的最终效果,就是让所有者信息在动画快播放完毕时显示。这样一来,所有者信息的显示就会与月亮落在屏幕右下角的运动协调一致。

通过让一个视图的动画延迟播放,同时让另一个视图像这样移动,您可以构建出让用户有动态感的令人惊艳的动画。

试试看

  1. 再次运行该应用,然后转到第 5 步,查看动画的实际效果。如果您点击月亮,它会按照从起始位置到结束位置的路径移动,并经过 KeyFrameSet 中指定的每个 KeyAttribute

由于您让月亮旋转了两整圈,因此,月亮现在会完成两次后空翻,并且所有者信息会延迟到动画快播放完毕时再显示。

自行探索

在学习最后一种类型 KeyFrame 之前,请尝试在 KeyAttributes 中修改其他标准属性。例如,尝试将 rotation 更改为 rotationX,以查看其生成的动画。

下面列出了您可以尝试的标准属性:

  • android:visibility
  • android:alpha
  • android:elevation
  • android:rotation
  • android:rotationX
  • android:rotationY
  • android:scaleX
  • android:scaleY
  • android:translationX
  • android:translationY
  • android:translationZ

丰富的动画效果涉及更改视图的颜色或其他属性。虽然 MotionLayout 可以使用 KeyAttribute 更改上一个任务中列出的所有标准属性,但您要使用 CustomAttribute 来指定所有其他属性。

CustomAttribute 可用于设置任何具有 setter 的值。例如,您可以使用 CustomAttribute 为视图设置 backgroundColorMotionLayout 会使用 reflection 查找 setter,然后反复调用它,以便为视图添加动画。

在这一步,您将使用 CustomAttribute 为月亮设置 colorFilter 属性,以便构建如下所示的动画。

定义自定义属性

  1. 首先,打开 xml/step6.xml,其中包含的动画与您在上一步中构建的相同。
  2. 若要让月亮更改颜色,请在 KeyFrameSet 中的 keyFrame="0"keyFrame="50"keyFrame="100". 处添加两个包含 CustomAttributeKeyAttribute

<!-- xml/step6.xml -->

<!-- TODO: Add Custom attributes here -->
<KeyAttribute
       app:framePosition="0"
       app:motionTarget="@id/moon">
   <CustomAttribute
           app:attributeName="colorFilter"
           app:customColorValue="#FFFFFF"
   />
</KeyAttribute>
<KeyAttribute
       app:framePosition="50"
       app:motionTarget="@id/moon">
   <CustomAttribute
           app:attributeName="colorFilter"
           app:customColorValue="#FFB612"
   />
</KeyAttribute>
<KeyAttribute
       app:framePosition="100"
       app:motionTarget="@id/moon">
   <CustomAttribute
           app:attributeName="colorFilter"
           app:customColorValue="#FFFFFF"
   />
</KeyAttribute>

您要在 KeyAttribute 中添加 CustomAttributeCustomAttribute 将应用于 KeyAttribute 指定的 framePosition

CustomAttribute 中,您必须指定一个 attributeName 和一个要设置的值。

  • app:attributeName 是这个自定义属性将要调用的 setter 的名称。在该示例中,系统将调用 Drawable 上的 setColorFilter
  • app:custom*Value 是名称中注明的类型的自定义值,在该示例中,自定义值是指定的颜色。

自定义值可以是以下任一类型:

  • 颜色
  • 整数
  • 浮点数
  • 字符串
  • 维度
  • 布尔值

借助此 API,MotionLayout 可以为在任何视图上提供 setter 的任何内容添加动画。

试试看

  1. 再次运行该应用,然后转到第 6 步,查看动画的实际效果。
    如果您点击月亮,它会按照从起始位置到结束位置的路径移动,并经过 KeyFrameSet 中指定的每个 KeyAttribute

当您添加更多 KeyFrames 时,MotionLayout 会将月亮的路径从直线更改为复杂的曲线,并在动画播放的中段添加两次后空翻、调整大小并更改颜色。

在真实的动画中,您通常需要同时为多个视图添加动画效果,以控制这些视图沿不同路径以不同速度移动。通过为每个视图指定不同的 KeyFrame,您可以使用 MotionLayout 编排丰富的动画,以为多个视图添加动画效果。

在此步骤中,您将了解如何将 OnSwipe 与复杂的路径结合使用。到目前为止,月亮的动画效果一直是由 OnClick 监听器触发的,并会在固定的时长内运行。

若要使用 OnSwipe 控制采用复杂路径的动画(例如您在前几个步骤中创建的月亮的动画),您必须了解 OnSwipe 的工作原理。

第 1 步:探索 OnSwipe 行为

  1. 打开 xml/step7.xml 并找到现有的 OnSwipe 声明。
<!-- xml/step7.xml -->

<!-- Fix OnSwipe by changing touchAnchorSide -->
<OnSwipe
       app:touchAnchorId="@id/moon"
       app:touchAnchorSide="bottom"
/>
  1. 在您的设备上运行该应用,然后转到第 7 步。看看您能否通过沿着弧线路径拖动月亮来生成顺畅的动画。

当您运行该动画时,其效果并不理想。当月亮到达弧线的顶部后,它就会开始来回跳动。

若要理解这个错误,您要考虑当用户轻触弧线顶部正下方时会发生什么情况。由于 OnSwipe 标记具有 app:touchAnchorSide="bottom",因此 MotionLayout 会在整个动画播放期间尝试让手指与视图底部之间的距离保持恒定。

但是,由于月亮底部并非始终朝同一方向移动,它会先上移,然后再往回下移,因此,用户的操作位置一越过弧线顶部,MotionLayout 就会无法确定要如何响应。考虑到这一点,由于您要跟踪月亮的底部,因此当用户轻触此处时,应该将它放置到哪个位置呢?

第 2 步:使用右侧

为避免出现此类错误,请务必始终选择在整个动画播放时长中始终朝一个方向移动的 touchAnchorIdtouchAnchorSide

在该动画中,月亮的 right 侧和 left 侧都会朝一个方向在屏幕上移动。

不过,bottomtop 都会反转方向。当跟踪它们时,如果它们更改方向,OnSwipe 就会困惑。

  1. 若要让该动画遵循触摸事件播放,请将 touchAnchorSide 更改为 right
<!-- xml/step7.xml -->

<!-- Fix OnSwipe by changing touchAnchorSide -->
<OnSwipe
       app:touchAnchorId="@id/moon"
       app:touchAnchorSide="right"
/>

第 3 步:使用 dragDirection

您还可以将 dragDirectiontouchAnchorSide 结合使用,使一侧所跟踪的方向与正常跟踪的方向不同。此时,仍然务必要让 touchAnchorSide 仅朝一个方向移动,但您可以指示 MotionLayout 跟踪哪个方向。例如,您可以保留 touchAnchorSide="bottom",但添加 dragDirection="dragRight"。这会导致 MotionLayout 跟踪视图底部的位置,但只有在向右移动时才考虑这个位置(它会忽略垂直运动)。因此,即使底部上下移动,它仍然会使用 OnSwipe 正确地添加动画效果。

  1. 更新 OnSwipe 以便正确地跟踪月亮的运动。
<!-- xml/step7.xml -->

<!-- Using dragDirection to control the direction of drag tracking -->
<OnSwipe
       app:touchAnchorId="@id/moon"
       app:touchAnchorSide="bottom"
       app:dragDirection="dragRight"
/>

试试看

  1. 再次运行该应用,然后尝试沿整个路径拖动月亮。
    尽管它的路径是复杂的弧线,但 MotionLayout 仍可播放动画以响应滑动事件。

MotionLayout 可与 CoordinatorLayout 一起用于构建丰富的动画效果。在这一步,您将使用 MotionLayout 构建一个可收起的标头。

第 1 步:探索现有代码

  1. 首先,打开 layout/activity_step8.xml
  2. layout/activity_step8.xml 中,您会看到已构建完成且可以运行的 CoordinatorLayoutAppBarLayout
<!-- layout/activity_step8.xml -->

<androidx.coordinatorlayout.widget.CoordinatorLayout
       ...>
   <com.google.android.material.appbar.AppBarLayout
           android:id="@+id/appbar_layout"
           android:layout_width="match_parent"
           android:layout_height="180dp">
       <androidx.constraintlayout.motion.widget.MotionLayout
               android:id="@+id/motion_layout"
               ... >
           ...
       </androidx.constraintlayout.motion.widget.MotionLayout>
   </com.google.android.material.appbar.AppBarLayout>

   <androidx.core.widget.NestedScrollView
           ...
           app:layout_behavior="@string/appbar_scrolling_view_behavior" >
           ...
   </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

该布局使用 CoordinatorLayoutNestedScrollViewAppBarLayout 之间共享滚动信息。因此,当 NestedScrollView 向上滚动时,它会将相应变化情况告知 AppBarLayout。在 Android 上,您可以采用这种方式来实现类似的折叠式工具栏,即文字的滚动将与标头的收起“协调一致”。

@id/motion_layout 指向的运动场景与上一步中的运动场景类似。不过,我们移除了 OnSwipe 声明,以使其能够与 CoordinatorLayout 配合使用。

  1. 运行该应用,然后转到第 8 步。您会发现,当您滚动文字时,月亮不会移动。

第 2 步:让 MotionLayout 滚动

  1. 若要让 MotionLayout 视图在 NestedScrollView 滚动时立即滚动,请向 MotionLayout 添加 app:minHeightapp:layout_scrollFlags
<!-- layout/activity_step8.xml -->
<!-- Add minHeight and layout_scrollFlags to the MotionLayout -->

<androidx.constraintlayout.motion.widget.MotionLayout
       android:id="@+id/motion_layout"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:layoutDescription="@xml/step8"
       app:motionDebug="SHOW_PATH"
       android:minHeight="80dp"
       app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed"  >
  1. 再次运行该应用,然后转到第 8 步
    您会发现,MotionLayout 会在您向上滚动时收起。不过,动画还无法基于滚动行为进行播放。

第 3 步:使用代码移动运动场景

  1. 打开 Step8Activity.kt。修改 coordinateMotion() 函数,以将滚动位置的变化情况告知 MotionLayout
// Step8Activity.kt
// TODO: set progress of MotionLayout based on an AppBarLayout.OnOffsetChangedListener

private fun coordinateMotion() {
    val appBarLayout: AppBarLayout = findViewById(R.id.appbar_layout)
    val motionLayout: MotionLayout = findViewById(R.id.motion_layout)

    val listener = AppBarLayout.OnOffsetChangedListener { unused, verticalOffset ->
        val seekPosition = -verticalOffset / appBarLayout.totalScrollRange.toFloat()
        motionLayout.progress = seekPosition
    }

    appBarLayout.addOnOffsetChangedListener(listener)
}

以上代码会注册一个 OnOffsetChangedListener,每当用户使用当前滚动偏移量进行滚动时,系统都会调用它。

MotionLayout 支持通过设置进度属性来定位其过渡情况。如需在 verticalOffset 与百分比进度之间进行转换,请用总滚动范围除以相应值。

试试看

  1. 再次部署该应用,并运行第 8 步的动画。您会发现 MotionLayout 会根据滚动位置来播放动画。

您可以使用 MotionLayout 来构建自定义的动态折叠式工具栏动画。通过使用 KeyFrames 序列,您可以实现非常精彩的效果。

此 Codelab 介绍了 MotionLayout 的基本 API。

如需查看更多 MotionLayout 的实际示例,请参阅官方示例。请务必查看相关文档

了解详情

MotionLayout 还支持此 Codelab 中未涵盖的更多功能,例如 KeyCycle,(可让您使用重复的周期来控制路径或属性)和 KeyTimeCycle,(可让您根据时钟时间来添加动画)。如需查看这两项功能的示例,请参阅相关范例。

如需本课程中其他 Codelab 的链接,请参阅“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页