20、MotionLayout进阶:KeyFrame与KeyTrigger、自定义属性动画、复杂交互场景实战

MotionLayout 的基础用法,说白了就是让两个布局之间做过渡动画。但实际项目中,哪有这么简单?

你想想看,用户滑动页面时,某个按钮要渐变、某个卡片要位移、背景色还要跟着变——这些都不是简单的起点到终点能搞定的。这时候,KeyFrame 和 KeyTrigger 就派上用场了。

我个人习惯把 MotionLayout 的进阶用法分成三层:关键帧控制触发机制自定义属性。今天咱们一层层拆开讲。

KeyFrame:在动画中途“插一脚”

默认的 MotionLayout 动画,就是从 ConstraintSet A 到 ConstraintSet B 的线性过渡。但很多时候,我们希望动画在中间某个位置“拐个弯”。

比如一个卡片从屏幕底部滑到顶部,我希望它滑到一半时先放大 1.2 倍,再恢复原样。这就是 KeyFrame 的典型场景。

核心概念:KeyFrame 允许你在动画进度 0~1 之间的任意位置,插入一个中间状态。

在 MotionScene 文件中,我们通过 <KeyFrameSet> 来定义这些中间状态。看个例子:

<Transition
    motion:constraintSetStart="@id/start"
    motion:constraintSetEnd="@id/end">

    <KeyFrameSet>
        <KeyPosition
            motion:keyPositionType="parentRelative"
            motion:percentX="0.5"
            motion:percentY="0.3"
            motion:framePosition="50"
            motion:motionTarget="@id/card" />

        <KeyAttribute
            motion:framePosition="30"
            motion:motionTarget="@id/card">
            <CustomAttribute
                motion:attributeName="scaleX"
                motion:customFloatValue="1.2" />
            <CustomAttribute
                motion:attributeName="scaleY"
                motion:customFloatValue="1.2" />
        </KeyAttribute>
    </KeyFrameSet>
</Transition>

这里 framePosition 的值是 0~100,表示动画进度的百分比。50 就是动画进行到一半时。

我在项目中遇到过一个问题:KeyPosition 的路径类型选错了,导致卡片走了一条诡异的弧线。后来发现 keyPositionTypeparentRelativedeltaRelativepathRelative 三种,默认是 pathRelative,但大多数场景用 parentRelative 更直观。

我的建议:刚开始用 KeyPosition 时,先画个草图,标出起点、终点、中间点的位置关系。不然调试路径会让你怀疑人生。

KeyTrigger:让动画“听用户的话”

KeyTrigger 是 MotionLayout 里一个很巧妙的设计。它允许你在动画进度到达某个阈值时,触发一个回调或者改变动画方向。

说白了,就是让动画和用户的交互行为绑定得更紧密。

举个例子:一个可拖拽的底部面板,用户拖到 60% 的位置时,自动展开到 100%;拖到 40% 以下时,自动回缩到 0%。

<KeyTrigger
    motion:framePosition="60"
    motion:onCross="autoComplete"
    motion:motionTarget="@id/dragHandle" />

<KeyTrigger
    motion:framePosition="40"
    motion:onCross="autoCompleteReverse"
    motion:motionTarget="@id/dragHandle" />

onCross 属性有几种取值:

取值 行为
autoComplete 越过阈值后,自动完成到终点
autoCompleteReverse 越过阈值后,自动回退到起点
jumpToStart 直接跳到起点
jumpToEnd 直接跳到终点

嗯,这里要注意:framePosition 的值是 0~100,但 KeyTrigger 的触发方向是双向的。也就是说,从 0 往 100 走时,经过 60 会触发;从 100 往回走时,经过 60 也会触发。如果你只想单向触发,需要配合 motion:crossDirection="startToEnd"endToStart 来限定。

我曾经踩过的坑:KeyTrigger 的 motionTarget 必须指定一个实际存在的 View ID,不能是父布局。否则触发条件永远不会满足,动画就像卡住了一样。

自定义属性动画:不止于位置和大小

MotionLayout 默认支持的是位置、旋转、缩放、透明度这些常见属性。但实际项目中,我们经常需要动画化一些“非标准”属性,比如背景色渐变、圆角变化、甚至自定义 View 的某个业务属性。

这时候就需要 CustomAttribute 了。

看个例子:让一个按钮的背景色从蓝色渐变到红色。

<Constraint
    android:id="@id/button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <CustomAttribute
        motion:attributeName="backgroundColor"
        motion:customColorValue="#2196F3" />
</Constraint>

然后在终点 ConstraintSet 里:

<Constraint
    android:id="@id/button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <CustomAttribute
        motion:attributeName="backgroundColor"
        motion:customColorValue="#F44336" />
</Constraint>

MotionLayout 会自动在两种颜色之间做插值。支持的类型有:

  • customColorValue:颜色值,自动做 ARGB 插值
  • customIntegerValue:整数
  • customFloatValue:浮点数
  • customStringValue:字符串(不支持插值,只能跳变)
  • customDimension:尺寸值,如 dp、px

我在项目中用 CustomAttribute 做过一个自定义进度条的颜色渐变效果。当时有个坑:自定义属性必须通过 setter 方法暴露。比如 backgroundColor 对应的是 setBackgroundColor(int) 方法。如果 View 没有对应的 setter,MotionLayout 会直接忽略这个属性,不会报错——嗯,调试起来很头疼。

一个小技巧:如果你需要动画化一个没有 setter 的属性,可以给 View 写一个扩展函数,用 @JvmStatic 注解暴露给 MotionLayout 调用。或者干脆用 View.setTag() 配合 customStringValue 做标记。

复杂交互场景实战:可拖拽的卡片列表

理论说完了,咱们来点实战。我设计了一个常见的交互场景:一个卡片列表,每张卡片可以上下拖拽,拖到一定位置后自动展开详情,或者回缩成卡片。

先画个流程图,看看整体逻辑:

可拖拽卡片列表 — 交互流程 用户触摸卡片 onTouch 事件 MotionLayout 接管 transitionToState() 拖拽过程中 progress 0~1 KeyTrigger 检测阈值 未达阈值 → 回缩 达到阈值 → 展开 动画完成

实现这个场景,核心思路是:

  1. 每个卡片是一个 MotionLayout 容器
  2. 定义三个 ConstraintSet:折叠态中间态展开态
  3. 通过 KeyTrigger 控制自动完成或回缩
  4. 用 CustomAttribute 动画化卡片阴影和圆角

MotionScene 的核心配置:

<Transition
    motion:constraintSetStart="@id/collapsed"
    motion:constraintSetEnd="@id/expanded"
    motion:duration="300">

    <OnSwipe
        motion:dragDirection="dragUp"
        motion:touchAnchorId="@id/cardContent"
        motion:touchAnchorSide="top" />

    <KeyFrameSet>
        <KeyTrigger
            motion:framePosition="60"
            motion:onCross="autoComplete"
            motion:motionTarget="@id/cardContent"
            motion:crossDirection="startToEnd" />

        <KeyTrigger
            motion:framePosition="40"
            motion:onCross="autoCompleteReverse"
            motion:motionTarget="@id/cardContent"
            motion:crossDirection="endToStart" />

        <KeyAttribute
            motion:framePosition="50"
            motion:motionTarget="@id/cardContent">
            <CustomAttribute
                motion:attributeName="cardElevation"
                motion:customFloatValue="12" />
        </KeyAttribute>
    </KeyFrameSet>
</Transition>

这里有个细节:crossDirection 我分别指定了 startToEndendToStart,这样向上拖和向下拖的触发阈值可以不一样。用户体验上,向上拖需要更用力(60%),向下拖则更灵敏(40%)。

注意:多个 KeyTrigger 的 framePosition 不要重叠。如果两个触发器的位置一样,MotionLayout 只会执行最后一个。我曾经因为这个 bug 排查了半天,最后发现是两个 KeyTrigger 的阈值都设成了 50。

性能优化:别让动画卡成 PPT

MotionLayout 的动画本质上是属性动画的封装,性能瓶颈通常出现在两个地方:

  • 布局层级过深:MotionLayout 本身是一个 ConstraintLayout,如果嵌套太多层,measure/layout 的开销会很大
  • 自定义属性频繁刷新:每帧都会调用 setter,如果 setter 里做了复杂计算,卡顿是必然的

我的经验是:把动画相关的 View 尽量扁平化。比如卡片列表,每个卡片内部不要超过两层布局。如果卡片里有图片,用 ImageView 配合 scaleType 处理,不要用 FrameLayout 包一层。

另外,CustomAttributecustomColorValue 在插值时,MotionLayout 内部会做 ARGB 通道的线性插值。如果你需要更复杂的颜色过渡(比如 HSL 插值),建议用 ArgbEvaluator 手动处理,然后通过 customIntegerValue 传递。

一个小优化:如果动画过程中不需要实时更新布局(比如只是位移和透明度变化),可以在 MotionLayout 的 onLayout 阶段把 requestLayout 禁掉。用 invalidate() 代替,能省掉不少 measure 的开销。

好了,KeyFrame、KeyTrigger、自定义属性动画,这三个东西组合起来,基本能覆盖 90% 的复杂交互场景。剩下的 10%,要么是设计稿太放飞自我,要么是产品经理又加需求了——嗯,那是另一个话题了。


公众号:蓝海资料掘金营,微信deep3321