Google通过发布新的便捷库和API不断改善我们的生活。 其中包括新的MotionLayout。 鉴于我们的应用程序中有大量动画,我的同事塞德里克·霍尔茨(Cedric Holtz)立即使用新的API实现了我们应用程序中最重要的动画-约会投票-使用新的API,同时节省了大量代码。 我分享他文章的翻译。Google I / O 2019大会近日结束,宣布了我们钟爱的SDK的更新和最新改进。 就个人而言,我对Nicholas Road和John Hoford的有关ConstraintLayout未来功能的演示特别感兴趣。 更具体地说,关于其以MotionLayout形式的扩展。
测试版发布后,我想基于此库实现一个约会动画。
首先,让我们定义术语:
“ MotionLayout是一种ConstraintLayout,可用于在不同状态之间设置布局动画。” - 文档
如果您尚未阅读Nicholas Road
撰写的
一系列文章,这些文章解释了MotionLayout的关键思想,那么我强烈建议您阅读它。
因此,在完成介绍之后,现在让我们看看我们想要得到什么:

卡堆
我们显示移动后的地图
首先,将MotionLayout添加到布局目录中,该目录到目前为止仅包含一张顶层卡片:
<androidx.constraintlayout.motion.widget.MotionLayout android:id="@+id/motionLayout" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutDescription="@xml/scene_swipe" app:motionDebug="SHOW_ALL"> <FrameLayout android:id="@+id/topCard" android:layout_width="0dp" android:layout_height="0dp" /> </androidx.constraintlayout.motion.widget.MotionLayout>
请注意以下行:app:motionDebug =“ SHOW_ALL”。 它使我们能够显示调试信息,对象的轨迹,动画开始和结束的状态以及当前进度。 该行在调试时有很大帮助,但不要忘记在将其发送到产品之前将其删除:对此没有任何提醒。
如您所见,我们在这里没有为视图设置任何限制。 它们将从我们现在定义的场景(MotionScene)中获取。
让我们从定义初始状态开始:一张卡位于屏幕中央,周围有凹痕。
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <ConstraintSet android:id="@+id/rest"> <Constraint android:id="@id/topCard" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginBottom="50dp" android:layout_marginEnd="50dp" android:layout_marginStart="50dp" android:layout_marginTop="50dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> </ConstraintSet> </MotionScene>
添加约束集(ConstraintSet)通过等。 当顶部卡完全向左或向右移动时,它们将反映顶部卡的状态。 我们希望地图停止运行,然后从屏幕上消失,以显示精美的动画来确认我们的决定。
<ConstraintSet android:id="@+id/pass" app:deriveConstraintsFrom="@+id/rest"> <Constraint android:id="@id/topCard" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginBottom="80dp" android:layout_marginEnd="200dp" android:layout_marginStart="50dp" android:layout_marginTop="20dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintWidth_percent="0.7" /> </ConstraintSet> <ConstraintSet android:id="@+id/like" app:deriveConstraintsFrom="@id/rest"> <Constraint android:id="@id/topCard" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginBottom="80dp" android:layout_marginEnd="50dp" android:layout_marginStart="200dp" android:layout_marginTop="20dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintWidth_percent="0.7" /> </ConstraintSet>
将两组约束添加到上一个场景。 它们几乎是相同的,仅在屏幕的两面都镜像。
现在我们有了三组限制-开始,喜欢和通过。 让我们定义这些状态之间的过渡。
为此,请在左侧添加一个过渡以进行滑动,在右侧添加另一个过渡以进行滑动。
<Transition app:constraintSetEnd="@+id/pass" app:constraintSetStart="@+id/rest" app:duration="300"> <OnSwipe app:dragDirection="dragLeft" app:onTouchUp="autoComplete" app:touchAnchorId="@id/topCard" app:touchAnchorSide="left" app:touchRegionId="@id/topCard" /> </Transition> <Transition app:constraintSetEnd="@+id/like" app:constraintSetStart="@+id/rest" app:duration="300"> <OnSwipe app:dragDirection="dragRight" app:onTouchUp="autoComplete" app:touchAnchorId="@+id/topCard" app:touchAnchorSide="right" app:touchRegionId="@id/topCard" /> </Transition>
因此,对于最上面的卡片,我们将滑动动画设置在左侧,而将相同动画-镜像设置在右侧。
这些属性将有助于改善与场景的交互:
- touchRegionId:由于我们在地图周围添加了填充,因此我们需要确保仅在地图本身的区域而不是整个MotionLayout中识别触摸。 可以使用touchRegionId完成。
- onTouchUp:放开卡片后动画会发生什么? 它应该继续前进或返回其初始状态,因此适用于autoComplete。
让我们看看发生了什么:

地图会自动从屏幕上消失
现在,我们将处理动画,动画将在地图离开屏幕时开始。
我们为动画的每个最终状态添加了另外两个ConstraintSet集:贴图离开屏幕的左侧和右侧。
在下面的示例中,我将显示如何设置like状态,并且pass状态将对其进行镜像。 在
存储库中可以完全看到一个工作示例。
<ConstraintSet android:id="@+id/offScreenLike"> <Constraint android:id="@id/topCard" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginBottom="80dp" android:layout_marginEnd="50dp" android:layout_marginTop="20dp" app:layout_constraintStart_toEndOf="parent" app:layout_constraintWidth_percent="0.7" /> </ConstraintSet>
现在,与前面的示例一样,您需要确定从滑动状态到最终状态的过渡。 滑动动画后,过渡应立即自动工作。 这可以使用autoTransition完成:
<Transition app:autoTransition="animateToEnd" app:constraintSetEnd="@+id/offScreenLike" app:constraintSetStart="@+id/like" app:duration="150" />
现在,我们有了可以从屏幕上刷卡的刷卡!

底图动画
现在让我们制作底牌,以创造甲板无限的错觉。
向布局添加一个地图,类似于第一个:
<FrameLayout android:id="@+id/bottomCard" android:layout_width="0dp" android:layout_height="0dp" android:background="@color/colorAccent" />
更改XML以设置在动画的每个阶段应用于此贴图的限制:
<ConstraintSet android:id="@id/rest"> <!-- ... --> <Constraint android:id="@id/bottomCard"> <Layout android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginBottom="50dp" android:layout_marginEnd="50dp" android:layout_marginStart="50dp" android:layout_marginTop="50dp" /> <Transform android:scaleX="0.90" android:scaleY="0.90" /> </Constraint> </ConstraintSet> <ConstraintSet android:id="@+id/offScreenLike" app:deriveConstraintsFrom="@id/like"> <!-- ... --> <Constraint android:id="@id/bottomCard"> <Transform android:scaleX="1" android:scaleY="1" /> </Constraint> </ConstraintSet>
为此,我们可以使用便捷的ConstraintSet属性。
默认情况下,每个新集都采用父级MotionLayout的属性。 但是,使用emergeConstraintsFrom标志,可以为我们的集合设置另一个父对象。 应该记住的是,如果我们使用约束标签设置约束,那么我们将从父集合中重新定义所有约束。 为避免这种情况,您可以在
标签中设置特定的属性,以便仅替换它们。

在我们的情况下,这意味着在通行证集中,我们没有定义Layout标签,而是从父级复制它。 但是,我们覆盖了Transform,因此,我们将Transform标记中指定的所有属性替换为我们自己的属性,在这种情况下,将更改比例。
这就是使用MotionLayout添加新元素并将其与场景的动画无缝集成起来非常容易的过程。

使动画永无止境
动画完成后,不能将顶部的卡片刷掉,因为现在它已成为底部的卡片。 要获得无尽的动画,您需要交换卡片。
首先,我想通过一个新的过渡来做到这一点:
<Transition app:autoTransition="jumpToEnd" app:constraintSetEnd="@+id/rest" app:constraintSetStart="@+id/offScreenLike" app:duration="0" />

整个动画将按预期播放。 现在,我们有一堆纸牌,您可以无休止地刷卡!
轻扫一下,我发现了一些东西。 触摸卡片时,将停止过渡到卡片组动画的结尾。 即使动画持续时间为零,它仍然会停止,这很糟糕。

我设法仅以一种方式获胜-通过以编程方式更改MotionLayout中的活动过渡。
为此,我们将在动画完成时设置一个回调。 一旦完成offScreenLike和offScreenPass,我们只需将转换重置回休止状态并将进度归零。
motionLayout.setTransitionListener(object : TransitionAdapter() { override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) { when (currentId) { R.id.offScreenPass, R.id.offScreenLike -> { motionLayout.progress = 0f motionLayout.setTransition(R.id.rest, R.id.like) } } } })
设置,通过或喜欢哪种过渡都没关系,滑动时切换到所需的过渡。

看起来一样,但是动画不会停止! 让我们继续前进!
资料绑定
创建测试数据以显示在地图上。 目前,我们将仅限于更改每张卡的背景颜色。
我们使用svayp方法创建一个ViewModel,该方法仅替换新数据。 通过以下方式将其绑定到活动:
val viewModel = ViewModelProviders .of(this) .get(SwipeRightViewModel::class.java) viewModel .modelStream .observe(this, Observer { bindCard(it) }) motionLayout.setTransitionListener(object : TransitionAdapter() { override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) { when (currentId) { R.id.offScreenPass, R.id.offScreenLike -> { motionLayout.progress = 0f motionLayout.setTransition(R.id.rest, R.id.like) viewModel.swipe() } } } })
仍然需要通知ViewModel滑动动画的完成,并且它将更新当前显示的数据。

弹出图标
添加两个视图,这些视图在滑动时会出现在屏幕的一侧(下面仅显示一个,第二个已镜像)。
<ImageView android:id="@+id/likeIndicator" android:layout_width="0dp" android:layout_height="0dp" />
现在,对于地图,您需要使用这些视图设置动画状态。
<ConstraintSet android:id="@id/rest"> <!-- ... --> <Constraint android:id="@+id/like"> <Layout android:layout_width="40dp" android:layout_height="40dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Transform android:scaleX="0.5" android:scaleY="0.5" /> <PropertySet android:alpha="0" /> </Constraint> </ConstraintSet> <ConstraintSet android:id="@+id/like" app:deriveConstraintsFrom="@id/rest"> <!-- ... --> <Constraint android:id="@+id/like"> <Layout android:layout_width="100dp" android:layout_height="100dp" app:layout_constraintBottom_toBottomOf="@id/topCard" app:layout_constraintEnd_toEndOf="@id/topCard" app:layout_constraintStart_toStartOf="@id/topCard" app:layout_constraintTop_toTopOf="@id/topCard" /> <Transform android:scaleX="1" android:scaleY="1" /> <PropertySet android:alpha="1" /> </Constraint> </ConstraintSet>
由于动画是从父级继承的,因此无需对超出屏幕的动画设置限制。 在我们的情况下,这是滑动状态。
这就是我们要做的。 现在,您可以非常轻松地将组件添加到动画链中。

以编程方式运行动画
我们可以在卡上创建两个按钮,以便用户不仅可以滑动,还可以使用按钮进行控制。
每个按钮与滑动一样开始相同的动画。
与往常一样,订阅按钮单击,然后直接在MotionLayout对象上启动动画:
likeButton.setOnClickListener { motionLayout.transitionToState(R.id.like) } passButton.setOnClickListener { motionLayout.transitionToState(R.id.pass) }
我们需要在顶部和底部卡片上都添加按钮,以便动画连续播放。 但是,对于下部地图,不需要单击订阅,因为它不可见或已为上部地图设置了动画,并且我们不想中断它。

MotionLayout如何为我们处理状态变化的另一个很好的例子。 让我们稍微放慢动画的速度:

看一下Pass替换时MotionLayout执行的过渡。 魔术!
沿曲线滑动地图
假设我们喜欢地图不是沿直线移动而是沿曲线移动(说实话,我只是想尝试这样做)。
然后,您需要为两个方向上的移动定义KeyPosition,以使移动路径被弧形弯曲。
将此添加到运动场景:
<Transition app:constraintSetEnd="@+id/like" app:constraintSetStart="@+id/rest" app:duration="300"> <!-- ... --> <KeyFrameSet> <KeyPosition app:drawPath="path" app:framePosition="50" app:keyPositionType="pathRelative" app:motionTarget="@id/topCard" app:percentX="0.5" app:percentY="-0.1" /> </KeyFrameSet> </Transition>

现在,地图沿着非平直的弯曲路径移动。 神奇地!
结论
当您将创建这些动画时获得的代码量与当前生产中类似动画的实现进行比较时,结果令人震惊。
MotionLayout不知不觉地处理取消过渡(例如,在触摸时),创建动画链,在过渡期间更改属性等等。 该工具从根本上改变了一切,从而大大简化了UI逻辑。
还有其他一些值得努力的事情(主要是在RecyclerView中禁用动画和双向滚动),但是我确信这是可以解决的。
请记住,该库仍处于beta状态,但它已经为我们带来了许多激动人心的机会。 我们期待着MotionLayout的发布,我相信它会在将来多次派上用场。 您可以从
存储库中的本文中看到功能全面的应用程序。
附注:由于他们让我担任翻译,因此我们的Android团队拥有开发人员的位置 。 谢谢您的关注。