20、MotionLayout进阶:KeyFrame与KeyTrigger、自定义属性动画、复杂交互场景实战
MotionLayout 的基础用法,说白了就是让两个布局之间做过渡动画。但实际项目中,哪有这么简单?
你想想看,用户滑动页面时,某个按钮要渐变、某个卡片要位移、背景色还要跟着变——这些都不是简单的起点到终点能搞定的。这时候,KeyFrame 和 KeyTrigger 就派上用场了。
我个人习惯把 MotionLayout 的进阶用法分成三层:关键帧控制、触发机制、自定义属性。今天咱们一层层拆开讲。
KeyFrame:在动画中途“插一脚”
默认的 MotionLayout 动画,就是从 ConstraintSet A 到 ConstraintSet B 的线性过渡。但很多时候,我们希望动画在中间某个位置“拐个弯”。
比如一个卡片从屏幕底部滑到顶部,我希望它滑到一半时先放大 1.2 倍,再恢复原样。这就是 KeyFrame 的典型场景。
在 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 的路径类型选错了,导致卡片走了一条诡异的弧线。后来发现 keyPositionType 有 parentRelative、deltaRelative、pathRelative 三种,默认是 pathRelative,但大多数场景用 parentRelative 更直观。
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 来限定。
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 会直接忽略这个属性,不会报错——嗯,调试起来很头疼。
@JvmStatic 注解暴露给 MotionLayout 调用。或者干脆用 View.setTag() 配合 customStringValue 做标记。
复杂交互场景实战:可拖拽的卡片列表
理论说完了,咱们来点实战。我设计了一个常见的交互场景:一个卡片列表,每张卡片可以上下拖拽,拖到一定位置后自动展开详情,或者回缩成卡片。
先画个流程图,看看整体逻辑:
实现这个场景,核心思路是:
- 每个卡片是一个 MotionLayout 容器
- 定义三个 ConstraintSet:折叠态、中间态、展开态
- 通过 KeyTrigger 控制自动完成或回缩
- 用 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 我分别指定了 startToEnd 和 endToStart,这样向上拖和向下拖的触发阈值可以不一样。用户体验上,向上拖需要更用力(60%),向下拖则更灵敏(40%)。
framePosition 不要重叠。如果两个触发器的位置一样,MotionLayout 只会执行最后一个。我曾经因为这个 bug 排查了半天,最后发现是两个 KeyTrigger 的阈值都设成了 50。
性能优化:别让动画卡成 PPT
MotionLayout 的动画本质上是属性动画的封装,性能瓶颈通常出现在两个地方:
- 布局层级过深:MotionLayout 本身是一个 ConstraintLayout,如果嵌套太多层,measure/layout 的开销会很大
- 自定义属性频繁刷新:每帧都会调用 setter,如果 setter 里做了复杂计算,卡顿是必然的
我的经验是:把动画相关的 View 尽量扁平化。比如卡片列表,每个卡片内部不要超过两层布局。如果卡片里有图片,用 ImageView 配合 scaleType 处理,不要用 FrameLayout 包一层。
另外,CustomAttribute 的 customColorValue 在插值时,MotionLayout 内部会做 ARGB 通道的线性插值。如果你需要更复杂的颜色过渡(比如 HSL 插值),建议用 ArgbEvaluator 手动处理,然后通过 customIntegerValue 传递。
onLayout 阶段把 requestLayout 禁掉。用 invalidate() 代替,能省掉不少 measure 的开销。
好了,KeyFrame、KeyTrigger、自定义属性动画,这三个东西组合起来,基本能覆盖 90% 的复杂交互场景。剩下的 10%,要么是设计稿太放飞自我,要么是产品经理又加需求了——嗯,那是另一个话题了。
公众号:蓝海资料掘金营,微信deep3321