此 Codelab 是“使用 Kotlin 进行高级 Android 开发”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘课程的价值,但并不强制要求这样做。“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页列出了所有课程 Codelab。
MotionLayout
是一个库,借助它,您可以向 Android 应用添加丰富的运动效果。它基于 ConstraintLayout,
,可让您使用 ConstraintLayout
为您可以构建的任何对象添加动画效果。
您可以使用 MotionLayout
同时为多个视图的位置、大小、可见性、alpha 值、颜色、高度、旋转度以及其他属性添加动画效果。使用声明性 XML,您可以创建涉及多个视图的协调动画,而这难以通过代码实现。
添加动画是改善应用体验的绝佳方式。您可以将动画用于以下用途:
- 显示变化 - 在不同状态之间添加动画,可让用户自然而然地跟踪界面中的变化情况。
- 吸引用户注意 - 使用动画吸引用户注意重要的界面元素。
- 构建精美的设计 - 在设计中添加有效的运动效果可让应用看起来更精美。
前提条件
此 Codelab 专为具备一定 Android 开发经验的开发者而设计。在尝试完成此 Codelab 之前,您应该具备以下条件:
- 了解如何使用 Android Studio 打造包含 activity 和基本布局的应用,以及如何在设备或模拟器上运行该应用。熟悉
ConstraintLayout
。如需详细了解ConstraintLayout
,请仔细阅读约束布局 Codelab。
您应执行的操作
- 使用
ConstraintSets
和MotionLayout
定义动画效果 - 根据拖动事件添加动画
- 使用
KeyPosition
更改动画 - 使用
KeyAttribute
更改属性 - 使用代码运行动画
- 使用
MotionLayout
为可收起的标头添加动画
所需条件
- Android Studio 4.0(
MotionLayout
编辑器仅适用于该版本的 Android Studio。)
若要下载示例应用,您可以执行以下操作之一:
…或从命令行使用下列命令克隆 GitHub 代码库:
$ git clone https://github.com/googlecodelabs/motionlayout.git
首先,您将构建一个动画以响应用户点击,从而将视图从屏幕顶端的起始位置移到屏幕底端的结束位置。
若要通过起始代码创建动画,您需要以下几项主要元素:
MotionLayout,
,它是ConstraintLayout
的子类。您要在MotionLayout
标记中指定要添加动画的所有视图。MotionScene,
,它是用于一个描述MotionLayout.
动画的 XML 文件。Transition,
是MotionScene
的一部分,用于指定动画的时长、触发因素以及如何移动视图。ConstraintSet
,用于指定过渡的起始和结束约束条件。
我们将逐个了解一下每个元素,首先从 MotionLayout
开始。
第 1 步:探索现有代码
MotionLayout
是 ConstraintLayout
的子类,因此在添加动画时,后者的所有功能它全都支持。若要使用 MotionLayout
,请在需要使用 ConstraintLayout.
的位置添加 MotionLayout
视图。
- 在
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
中的动画。
若要让布局使用运动场景,布局必须指向相应场景。
- 为此,请打开设计图面。在 Android Studio 4.0 中,您可以在查看布局 XML 文件时使用右上角的图标打开设计图面。
- 打开设计图面后,右键点击预览,然后选择 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
中的约束条件。
在这一步,您要限制星形视图,使其在屏幕顶部的起始位置开始,并在屏幕底部的结束位置结束。
- 在
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>
ConstraintSet
的 id
为 @id/start
,它会指定要应用于 MotionLayout
中的所有视图的所有约束条件。由于这个 MotionLayout
只有一个视图,因此它只需要一个 Constraint
。
ConstraintSet
内的 Constraint
会指定要限制的视图的 ID,即 activity_step1.xml
中定义的 @id/red_star
。请务必注意,Constraint
标记仅会指定约束条件和布局信息。Constraint
标记并不知道自己将应用于 ImageView
。
该约束条件会指定高度、宽度以及将 red_star
约束到其父级顶部的起始位置所需的两个其他约束条件。
- 接下来,在
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
。此外,过渡还可以指定如何以其他方式修改动画,例如运行动画的时长,或如何通过拖动视图来添加动画。
- 在
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
的点击事件。
- 若要让
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
是用于监视点击的视图。- 在点击鼠标时,
toggle
的clickAction
会在起始和结束状态之间进行切换。您可以在相关文档中查看clickAction
的其他选项。
- 运行您的代码,点击第 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 步:检查起始代码
- 首先,打开布局文件
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
- 打开运动场景
xml/step2.xml
以定义动画。 - 为起始约束条件
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
中,您需为每个星形图案分别指定一个 Constraint
。MotionLayout
会在动画启动后应用各项约束条件。
我们已使用起始、结束和底部约束条件让每个星形图案视图都在屏幕底部居中。@id/left_star
和 @id/right_star
这两个星形图案都有一个额外的 alpha 值,该值可让它们处于不可见状态,并会在动画启动时应用。
start
和 end
约束条件集定义了动画的起始和结束。起始时的约束条件(如 app:layout_constraintStart_toStartOf
)会将一个视图的起始位于约束到另一个视图的起始位置。乍一看,这可能会令人困惑,因为二者都用到了 start
这个名称,并且二者都用于约束条件的上下文。为便于区分,我们规定 layout_constraintStart
中的 start
是指视图的“起始位置”。在从左到右书写的语言中,该位置位于左侧;在从右向左书写的语言中,该位置位于右侧。start
约束条件集是指动画的起始位置。
定义结束 ConstraintSet
- 将结束约束条件定义为使用链将 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
标记来添加此类动画。
- 将用于添加
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 的距离。
试试看
- 再次运行该应用,然后打开第 2 步对应的屏幕。您会看到相应的动画效果。
- 试着在动画播放过程中“快速滑动”或松开手指,了解
MotionLayout
如何显示流畅的基于物理特性的动画!
MotionLayout
可以使用 ConstraintLayout
中的功能在截然不同的设计之间添加动画,以便打造丰富的效果。
在该动画中,3 个视图在起始时都以其父级为参照物,定位在屏幕底部。结束时,3 个视图是以链中的 @id/credits
为参照物进行定位的。
尽管布局截然不同,但 MotionLayout
会在起始位置和结束位置之间创建流畅的动画。
在这一步,您将构建一个动画,使其在动画播放期间遵循复杂的路径,并在运动期间让所有者信息以动画方式显示。MotionLayout
可以使用 KeyPosition
修改视图在起始位置和结束位置之间采用的路径。
第 1 步:探索现有代码
- 打开
layout/activity_step3.xml
和xml/step3.xml
,查看现有的布局和运动场景。ImageView
和TextView
分别用于显示月亮和所有者信息文字。 - 打开运动场景文件 (
xml/step3.xml
)。
此时,您会发现我们已经定义了一个从@id/start
到@id/end
的Transition
。该动画使用两个ConstraintSets
将月亮图片从屏幕左下角移到屏幕右下角。随着月亮的移动,所有者信息会从alpha="0.0"
淡入到alpha="1.0"
。 - 立即运行该应用,然后选择第 3 步。
此时您会发现,如果您点击月亮,它会沿一条线性路径(直线)从起始位置移到结束位置。
第 2 步:启用路径调试功能
在将弧形路径添加到月球的运动之前,最好在 MotionLayout
中启用路径调试功能。
为了帮助您使用 MotionLayout
开发复杂的动画,您可以绘制每个视图的动画路径。如果您要直观呈现动画,以及对运动的小细节进行微调的话,这会很有用。
- 如需启用调试路径功能,请打开
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
来修改视图的起始位置和结束位置之间的路径。
- 打开
xml/step3.xml
并向场景添加KeyPosition
。KeyPosition
标记位于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>
KeyFrameSet
是 Transition
的子级,也是在过渡期间应该应用的所有 KeyFrames
(例如 KeyPosition
)的集合。
由于 MotionLayout
会计算月亮在起始位置和结束位置之间的路径,因此它会根据 KeyFrameSet
中指定的 KeyPosition
来修改路径。您可以再次运行该应用,以查看这项操作会如何修改路径。
KeyPosition
有多个属性,用于描述它会如何修改路径。其中最重要的属性如下:
framePosition
是一个介于 0 到 100 之间的数字。它定义了在动画中应用该KeyPosition
的时间,其中 1 代表动画播放到 1% 的位置,99 代表动画播放到 99% 的位置。因此,如果该值为 50,则表示您要在动画播放到正中间时应用它。motionTarget
是被该KeyPosition
修改路径的视图。keyPositionType
是该KeyPosition
修改路径的方式,它可以是parentRelative
、pathRelative
或deltaRelative
(如下一步所述)。percentX | percentY
是指在framePosition
按多大百分比来修改路径(值介于 0.0 到 1.0 之间,允许使用负数值和大于 1 的值)。
您可以这样理解它:“在 framePosition
修改 motionTarget
的路径,方法是根据 keyPositionType
确定的坐标,将路径移动 percentX
或 percentY
。”
默认情况下,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
。
在上一步,您使用 parentRelative
的 keyPosition
类型将路径偏移了屏幕的 50%。keyPositionType
属性用于确定 MotionLayout 将如何根据 percentX
或 percentY
来修改路径。
<KeyFrameSet>
<KeyPosition
app:framePosition="50"
app:motionTarget="@id/moon"
app:keyPositionType="parentRelative"
app:percentY="0.5"
/>
</KeyFrameSet>
您可能会用到 3 种不同类型的 keyPosition
:parentRelative
、pathRelative
和 deltaRelative
。指定类型将会更改用于计算 percentX
和 percentY
的坐标系。
什么是坐标系?
坐标系提供了用于在空间中指定一个点的位置的方法。此外,坐标系也可用于描述屏幕上的位置。
MotionLayout
坐标系属于笛卡尔坐标系。这意味着,它们具有由两条互相垂直的直线定义的 X 轴和 Y 轴。它们的主要区别在于 X 轴在屏幕上的位置(Y 轴始终与 X 轴垂直)。
MotionLayout
中的所有坐标系在 X 轴和 Y 轴上使用的值都介于 0.0
和 1.0
之间。它们允许使用负值和大于 1.0
的值。例如,如果 percentX
的值为 -2.0
,则表示沿 X 轴的反方向移动两次。
如果以上内容听起来有点太像代数课,请查看以下图片!
parentRelative 坐标
parentRelative
的 keyPositionType
使用与屏幕相同的坐标系。它会将 (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
以进一步修改路径,从而实现任何运动。对于该动画,您将构建一个弧线,但如果您愿意,您可以让月亮在屏幕中间上下挑动。
- 打开
xml/step4.xml
。您会发现,它的视图和KeyFrame
与您在上一步添加的一样。 - 若要将曲线顶部变圆,可以在
@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
的流畅的运动。
试试看
- 再次运行该应用。转到第 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
。
构建动态动画通常意味着,要随着动画的播放不断更改视图的 size
、rotation
或 alpha
。MotionLayout
支持使用 KeyAttribute
为任意视图上的许多属性添加动画。
在这一步,您将使用 KeyAttribute
让月亮进行缩放和旋转。此外,您还将使用 KeyAttribute
将文字的显示时间延迟到月亮的移动过程接近完成时。
第 1 步:使用 KeyAttribute 调整大小和进行旋转
- 打开
xml/step5.xml
,其中包含的动画与您在上一步中构建的相同。
为了换换口味,该屏幕使用其他太空图片作为背景。 - 为了让月亮实现放大和旋转,请在
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 度,即两整圈),并将视图缩小为正常大小,因为 scaleX
和 scaleY
的值默认为 1.0。
与 KeyPosition
一样,KeyAttribute
使用 framePosition
和 motionTarget
来指定何时应用 KeyFrame
以及要修改哪个视图。MotionLayout
会在 KeyPositions
之间插值,以创建流畅的动画。
KeyAttributes
支持可应用于所有视图的属性。它们支持更改 visibility
、alpha
或 elevation
等基本属性。此外,您还可以像完成以上设置时一样更改旋转角度,使用 rotateX
和 rotateY
让视图在 3 个维度上旋转,使用 scaleX
或 scaleY
缩放视图的大小,或平移视图在 X 轴、Y 轴 或 Z 轴上的位置。
第 2 步:延迟所有者信息的显示时间
这一步的目的之一是更新动画,让所有者信息文字在动画快播放完毕时再显示。
- 若要延迟所有者信息的显示时间,请再定义一个
KeyAttribute
,用于确保alpha
在keyPosition="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/credits
的 alpha
在动画的前 85% 的内容中一直为 0.0。由于它的起始 alpha 值为 0,因此这意味着它在动画的前 85% 的内容中是不可见的。
该 KeyAttribute
的最终效果,就是让所有者信息在动画快播放完毕时显示。这样一来,所有者信息的显示就会与月亮落在屏幕右下角的运动协调一致。
通过让一个视图的动画延迟播放,同时让另一个视图像这样移动,您可以构建出让用户有动态感的令人惊艳的动画。
试试看
- 再次运行该应用,然后转到第 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
为视图设置 backgroundColor。MotionLayout
会使用 reflection 查找 setter,然后反复调用它,以便为视图添加动画。
在这一步,您将使用 CustomAttribute
为月亮设置 colorFilter
属性,以便构建如下所示的动画。
定义自定义属性
- 首先,打开
xml/step6.xml
,其中包含的动画与您在上一步中构建的相同。 - 若要让月亮更改颜色,请在
KeyFrameSet
中的keyFrame="0"
、keyFrame="50"
和keyFrame="100".
处添加两个包含CustomAttribute
的KeyAttribute
。
<!-- 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
中添加 CustomAttribute
。CustomAttribute
将应用于 KeyAttribute
指定的 framePosition
。
在 CustomAttribute
中,您必须指定一个 attributeName
和一个要设置的值。
app:attributeName
是这个自定义属性将要调用的 setter 的名称。在该示例中,系统将调用Drawable
上的setColorFilter
。app:custom*Value
是名称中注明的类型的自定义值,在该示例中,自定义值是指定的颜色。
自定义值可以是以下任一类型:
- 颜色
- 整数
- 浮点数
- 字符串
- 维度
- 布尔值
借助此 API,MotionLayout
可以为在任何视图上提供 setter 的任何内容添加动画。
试试看
- 再次运行该应用,然后转到第 6 步,查看动画的实际效果。
如果您点击月亮,它会按照从起始位置到结束位置的路径移动,并经过KeyFrameSet
中指定的每个KeyAttribute
。
当您添加更多 KeyFrames
时,MotionLayout
会将月亮的路径从直线更改为复杂的曲线,并在动画播放的中段添加两次后空翻、调整大小并更改颜色。
在真实的动画中,您通常需要同时为多个视图添加动画效果,以控制这些视图沿不同路径以不同速度移动。通过为每个视图指定不同的 KeyFrame
,您可以使用 MotionLayout
编排丰富的动画,以为多个视图添加动画效果。
在此步骤中,您将了解如何将 OnSwipe
与复杂的路径结合使用。到目前为止,月亮的动画效果一直是由 OnClick
监听器触发的,并会在固定的时长内运行。
若要使用 OnSwipe
控制采用复杂路径的动画(例如您在前几个步骤中创建的月亮的动画),您必须了解 OnSwipe
的工作原理。
第 1 步:探索 OnSwipe 行为
- 打开
xml/step7.xml
并找到现有的OnSwipe
声明。
<!-- xml/step7.xml -->
<!-- Fix OnSwipe by changing touchAnchorSide -->
<OnSwipe
app:touchAnchorId="@id/moon"
app:touchAnchorSide="bottom"
/>
- 在您的设备上运行该应用,然后转到第 7 步。看看您能否通过沿着弧线路径拖动月亮来生成顺畅的动画。
当您运行该动画时,其效果并不理想。当月亮到达弧线的顶部后,它就会开始来回跳动。
若要理解这个错误,您要考虑当用户轻触弧线顶部正下方时会发生什么情况。由于 OnSwipe
标记具有 app:touchAnchorSide="bottom"
,因此 MotionLayout
会在整个动画播放期间尝试让手指与视图底部之间的距离保持恒定。
但是,由于月亮底部并非始终朝同一方向移动,它会先上移,然后再往回下移,因此,用户的操作位置一越过弧线顶部,MotionLayout
就会无法确定要如何响应。考虑到这一点,由于您要跟踪月亮的底部,因此当用户轻触此处时,应该将它放置到哪个位置呢?
第 2 步:使用右侧
为避免出现此类错误,请务必始终选择在整个动画播放时长中始终朝一个方向移动的 touchAnchorId
和 touchAnchorSide
。
在该动画中,月亮的 right
侧和 left
侧都会朝一个方向在屏幕上移动。
不过,bottom
和 top
都会反转方向。当跟踪它们时,如果它们更改方向,OnSwipe
就会困惑。
- 若要让该动画遵循触摸事件播放,请将
touchAnchorSide
更改为right
。
<!-- xml/step7.xml -->
<!-- Fix OnSwipe by changing touchAnchorSide -->
<OnSwipe
app:touchAnchorId="@id/moon"
app:touchAnchorSide="right"
/>
第 3 步:使用 dragDirection
您还可以将 dragDirection
与 touchAnchorSide
结合使用,使一侧所跟踪的方向与正常跟踪的方向不同。此时,仍然务必要让 touchAnchorSide
仅朝一个方向移动,但您可以指示 MotionLayout
跟踪哪个方向。例如,您可以保留 touchAnchorSide="bottom"
,但添加 dragDirection="dragRight"
。这会导致 MotionLayout
跟踪视图底部的位置,但只有在向右移动时才考虑这个位置(它会忽略垂直运动)。因此,即使底部上下移动,它仍然会使用 OnSwipe
正确地添加动画效果。
- 更新
OnSwipe
以便正确地跟踪月亮的运动。
<!-- xml/step7.xml -->
<!-- Using dragDirection to control the direction of drag tracking -->
<OnSwipe
app:touchAnchorId="@id/moon"
app:touchAnchorSide="bottom"
app:dragDirection="dragRight"
/>
试试看
- 再次运行该应用,然后尝试沿整个路径拖动月亮。
尽管它的路径是复杂的弧线,但MotionLayout
仍可播放动画以响应滑动事件。
MotionLayout
可与 CoordinatorLayout
一起用于构建丰富的动画效果。在这一步,您将使用 MotionLayout
构建一个可收起的标头。
第 1 步:探索现有代码
- 首先,打开
layout/activity_step8.xml
。 - 在
layout/activity_step8.xml
中,您会看到已构建完成且可以运行的CoordinatorLayout
和AppBarLayout
。
<!-- 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>
该布局使用 CoordinatorLayout
在 NestedScrollView
和 AppBarLayout
之间共享滚动信息。因此,当 NestedScrollView
向上滚动时,它会将相应变化情况告知 AppBarLayout
。在 Android 上,您可以采用这种方式来实现类似的折叠式工具栏,即文字的滚动将与标头的收起“协调一致”。
@id/motion_layout
指向的运动场景与上一步中的运动场景类似。不过,我们移除了 OnSwipe
声明,以使其能够与 CoordinatorLayout
配合使用。
- 运行该应用,然后转到第 8 步。您会发现,当您滚动文字时,月亮不会移动。
第 2 步:让 MotionLayout 滚动
- 若要让
MotionLayout
视图在NestedScrollView
滚动时立即滚动,请向MotionLayout
添加app:minHeight
和app: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" >
- 再次运行该应用,然后转到第 8 步。
您会发现,MotionLayout
会在您向上滚动时收起。不过,动画还无法基于滚动行为进行播放。
第 3 步:使用代码移动运动场景
- 打开
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
与百分比进度之间进行转换,请用总滚动范围除以相应值。
试试看
- 再次部署该应用,并运行第 8 步的动画。您会发现
MotionLayout
会根据滚动位置来播放动画。
您可以使用 MotionLayout
来构建自定义的动态折叠式工具栏动画。通过使用 KeyFrames
序列,您可以实现非常精彩的效果。
此 Codelab 介绍了 MotionLayout
的基本 API。
如需查看更多 MotionLayout
的实际示例,请参阅官方示例。请务必查看相关文档!
了解详情
MotionLayout
还支持此 Codelab 中未涵盖的更多功能,例如 KeyCycle,
(可让您使用重复的周期来控制路径或属性)和 KeyTimeCycle,
(可让您根据时钟时间来添加动画)。如需查看这两项功能的示例,请参阅相关范例。
如需本课程中其他 Codelab 的链接,请参阅“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页。