深入理解 Reanimated 布局动画:从 Participant 组件内部结构到 Animated.View 的接入改造
【免费下载链接】react-native-reanimatedReact Native's Animated library reimplemented项目地址: https://gitcode.com/GitHub_Trending/re/react-native-reanimated
导读
在 React Native 中,组件的挂载与卸载默认是瞬间完成的——新增一个列表项,它立即出现在界面上;移除一个列表项,它在下一帧直接消失。react-native-reanimated 从 v2.3.0 开始引入的Layout Animations(布局动画)正是为了解决这一痛点:它允许你为组件的「进入(entering)」「退出(exiting)」以及「布局位置变化(layout)」三类场景接入预设动画,让列表增删、弹窗显隐等交互变得平滑自然。
本文以 Reanimated 官方教程中的参与者(Participant)列表为例,剖析待动画化组件的内部结构(_participantInternals.md),并以此为起点逐步完成从普通View到Animated.View的改造,最终叠加entering、layout、exiting三类动画。读完本文,你将掌握:哪些组件可以被布局动画驱动、为什么必须使用 Reanimated 提供的动画组件、以及如何用LightSpeedInLeft、LightSpeedOutRight、Layout.springify()在真实列表场景中落地。
一、教程背景:准备动画化的 Participant 列表
官方教程(见 animated_list.mdx)基于一个已有的「参与者列表」界面:用户可以通过底部输入框添加参与者姓名,也可以点击每个列表项上的红色 Remove 按钮将其删除。列表由ScrollView承载,通过participantList.map(...)渲染出多个Participant组件:
<ScrollView style={[{ width: '100%' }]}> {participantList.map((participant) => ( <Participant key={participant.id} name={participant.name} onRemove={() => removeParticipant(participant.id)} /> ))} </ScrollView>列表数据层的完整逻辑(完整代码见 _fullCode.md)如下:
const [inputValue, setInputValue] = useState(''); const [participantList, setParticipantList] = useState<EventParticipant[]>([]); const addParticipant = () => { setParticipantList( [{ name: inputValue, id: Date.now().toString() }].concat(participantList) ); setInputValue(''); }; const removeParticipant = (id: string) => { setParticipantList( participantList.filter((participant) => participant.id !== id) ); };可以看到:
- 新参与者通过
concat插入到数组头部,因此会出现在列表最上方; - 删除时通过
filter按id过滤,对应行会被移除; key={participant.id}使用Date.now().toString()生成唯一 id,保证 React 能正确识别每一个列表项。
在没有布局动画时,插入与删除都是「闪现」式的:新项瞬间出现、被删项瞬间消失,其余项瞬间上移/下移填补空位。这正是后续要改造的体验痛点。
二、Participant 组件内部结构解析(关联文档核心)
Participant组件负责渲染单个列表项,它是我们后续所有动画的载体。其内部结构(见 _participantInternals.md)非常简单——只包含一个根View、一行姓名文本和一个红色删除按钮:
function Participant({ name, onRemove, }: { name: string; onRemove: () => void; }) { return ( <View style={[styles.participantView]}> <Text>{name}</Text> <Button title="Remove" color="red" onPress={onRemove} /> </View> ); }拆解该组件的职责:
| 部分 | 说明 |
|---|---|
根View(styles.participantView) | 列表项的容器,负责整行的布局与样式,是布局动画要挂载的位置 |
Text | 展示参与者姓名name |
Button(红色Remove) | 触发onRemove回调,从列表中删除当前项 |
2.1 为什么动画必须加在根 View 上
教程明确指出:Participant 组件被包在一个 View 组件中,这正是我们添加动画的位置。原因在于布局动画需要作用于「一个独立、可整体感知自身几何变化」的视图节点:
- 进入/退出动画:需要视图从初始状态(如屏幕外偏移 + 倾斜 + 透明)过渡到最终状态,或反向过渡,这要求动画作用在代表整个列表项的单个视图上;
- 布局动画:当列表项因兄弟节点增删而改变位置或尺寸时,动画需要驱动该视图的
originX/originY/width/height从旧值过渡到新值。
如果把动画拆散到内部的Text或Button上,整个列表项的进入/退出/位移就会失去整体性,效果割裂且难以维护。
2.2 可动画化的组件边界:Animated.View 与 createAnimatedComponent
这是整个教程中最重要的技术约束:我们只能对由 Reanimated 提供的组件(如Animated.View),或通过createAnimatedComponent包装过的自定义组件施加布局动画。普通 React Native 的View不具备 Reanimated 的动画挂载点,传入entering/exiting/layout属性不会产生任何效果。
从当前仓库源码 Animated.ts 可以看到,Reanimated 的Animated命名空间导出了一组内置动画组件:
export { ReanimatedFlatList as FlatList } from './component/FlatList'; export { AnimatedImage as Image } from './component/Image'; export { AnimatedScrollView as ScrollView } from './component/ScrollView'; export { AnimatedText as Text } from './component/Text'; export { AnimatedView as View } from './component/View'; export { createAnimatedComponent } from './createAnimatedComponent';也就是说:
- 常用基础组件(
View、Text、Image、ScrollView、FlatList)都有对应的Animated.*版本,可直接使用; - 对于自定义组件或第三方组件,则需要通过
createAnimatedComponent(由 src/index.ts 对外导出)包装后使用,例如const AnimatedCustomView = createAnimatedComponent(CustomView);。
在本文的列表场景中,Participant的根元素是基础View,因此最直接的做法就是用Animated.View替换它。
三、Step 1:将 View 替换为 Animated.View
改造的第一步(见 _step1.md)是引入Animated并将根View换成Animated.View:
import Animated from 'react-native-reanimated'; function Participant({ name, onRemove, }: { name: string; onRemove: () => void; }) { return ( <Animated.View style={[styles.participantView]}> <Text>{name}</Text> <Button title="Remove" color="red" onPress={onRemove} /> </Animated.View> ); }这一步本身不会产生可见的动画,它的意义在于:为组件打开接收 Reanimated 布局动画属性的通道。替换之后,Animated.View实例就能识别并处理entering、exiting、layout三个专有属性。样式styles.participantView保持不变,因此界面上没有任何视觉回归。
四、Step 2:添加进入动画 entering
有了Animated.View之后,就可以叠加进入动画(见 _step2.md)。Reanimated 内置了大量预设的进入动画(如FadeIn、SlideInRight、ZoomIn、BounceIn等),这里教程选择视觉效果强烈的LightSpeedInLeft:
import Animated, {LightSpeedInLeft} from 'react-native-reanimated'; function Participant({ name, onRemove, }: { name: string; onRemove: () => void; }) { return ( <Animated.View entering={LightSpeedInLeft} style={[styles.participantView]}> <Text>{name}</Text> <Button title="Remove" color="red" onPress={onRemove} /> </Animated.View> ); }效果:每当新参与者被添加到列表(组件挂载)时,它会带着「从左侧高速飞入 + 倾斜摆动 + 透明度渐变」的复合效果出现,而不是瞬间闪现。
4.1 LightSpeed 系列动画的底层实现
LightSpeedInLeft并非黑盒魔法,它在当前仓库源码 Lightspeed.ts 中定义,继承自ComplexAnimationBuilder,其build()方法返回一个在 UI 线程运行的 worklet,核心动画编排如下:
return (values: EntryExitAnimationsValues) => { 'worklet'; return { animations: { opacity: delayFunction( delay, withTiming(targetValues?.opacity ?? 1, { duration }) ), transform: [ { translateX: delayFunction( delay, animation(targetTranslateX, { ...config, duration: duration * 0.7 }) ), }, { skewX: delayFunction( delay, withSequence( withTiming('-10deg', { duration: duration * 0.7 }), withTiming('5deg', { duration: duration * 0.15 }), withTiming(targetSkewX, { duration: duration * 0.15 }) ) ), }, ], }, initialValues: { opacity: initialValues?.opacity ?? 0, transform: pickTransformValues( [{ translateX: -values.windowWidth }, { skewX: '45deg' }], initialValues ), }, }; };从这段实现可以读出几个关键细节:
- 初始状态:
translateX从-windowWidth(屏幕宽度之外的左侧)开始,skewX初始为45deg,opacity从 0 开始; - 位移动画:水平位移在 70% 的时长内完成主要行程,营造高速冲刺感;
- 倾斜动画:用
withSequence串联三段withTiming(-10deg → 5deg → 0deg),分别占用 70% / 15% / 15% 的时长,产生「冲过头再回正」的摆动回弹效果; - 透明度:与位移同步从 0 过渡到 1。
同一文件还定义了LightSpeedInRight、LightSpeedOutLeft、LightSpeedOutRight等系列动画,方向相反、编排对称。这也解释了为什么教程标题强调「组件内部结构」——动画的全部初始值与关键帧编排,都建立在列表项这个单一视图的几何信息之上。
五、Step 3:添加布局过渡 layout
接下来为列表项添加布局过渡动画(见 _step3.md)。这一步解决的是「兄弟项移动」的动画:当某个参与者被删除后,它下方的所有列表项会向上移动填补空位;当新项插入时,原有项会向下让位。默认情况下这些位移是瞬间完成的,通过layout属性可以让它们平滑过渡:
import Animated, { LightSpeedInLeft, Layout } from 'react-native-reanimated'; function Participant({ name, onRemove, }: { name: string; onRemove: () => void; }) { return ( <Animated.View entering={LightSpeedInLeft} layout={Layout.springify()} style={[styles.participantView]}> <Text>{name}</Text> <Button title="Remove" color="red" onPress={onRemove} /> </Animated.View> ); }这里的Layout是线性过渡LinearTransition的别名。查看源码 LinearTransition.ts 末尾:
/** @deprecated Please use {@link LinearTransition} instead. */ export const Layout = LinearTransition;LinearTransition的build()会返回一个 worklet,它基于布局前后快照,对originX、originY、width、height四个几何量做插值过渡:
return (values) => { 'worklet'; return { initialValues: { originX: values.currentOriginX, originY: values.currentOriginY, width: values.currentWidth, height: values.currentHeight, }, animations: { originX: delayFunction(delay, animation(values.targetOriginX, config)), originY: delayFunction(delay, animation(values.targetOriginY, config)), width: delayFunction(delay, animation(values.targetWidth, config)), height: delayFunction(delay, animation(values.targetHeight, config)), }, }; };也就是说:布局动画的实质是「记录当前位置/尺寸 → 在下一帧得知目标位置/尺寸 → 让几何属性平滑变化」。
5.1 链式修饰器:springify 与更多定制手段
Layout.springify()中的springify是一个链式修饰器(modifier),来自基类AnimationConfigBuilder(实现于 ComplexAnimationBuilder.ts)。其核心实现:
springify(duration?: number): this { this.durationV = duration; this.type = withSpring as AnimationFunction; return this; }它做了两件事:
- 将底层动画函数替换为
withSpring(弹簧动画),使过渡带有一点弹性过冲; - 可选地传入一个以毫秒为单位的
duration来约束弹簧时长。
同一个基类还提供了一系列可链式组合的修饰器,例如:
duration(milliseconds):设置动画时长;delay(milliseconds):设置动画延迟;easing(easingFunction):自定义缓动曲线;dampingRatio(ratio)/damping(damping)/stiffness(stiffness)/mass(mass):调整弹簧物理参数;withCallback(callback):动画结束时回调;rotate(degree)、randomDelay()等。
这些修饰器同样作用于entering/exiting预设动画,例如LightSpeedInLeft.springify()或LightSpeedInLeft.duration(500),这正是源码注释中所说的「You can modify the behavior by chaining methods like.springify()or.duration(500)」。
六、Step 4:添加退出动画 exiting
最后一步为列表项添加退出动画(见 _step4.md)。与进入动画对称,Reanimated 也提供了丰富的预设退出动画,教程选用LightSpeedOutRight:
import Animated, { LightSpeedInLeft, LightSpeedOutRight, Layout } from 'react-native-reanimated'; function Participant({ name, onRemove, }: { name: string; onRemove: () => void; }) { return ( <Animated.View entering={LightSpeedInLeft} exiting={LightSpeedOutRight} layout={Layout.springify()} style={[styles.participantView]}> <Text>{name}</Text> <Button title="Remove" color="red" onPress={onRemove} /> </Animated.View> ); }至此,Animated.View上同时挂载了三类动画:
| 属性 | 取值 | 触发时机 | 动画效果 |
|---|---|---|---|
entering | LightSpeedInLeft | 组件挂载(添加参与者) | 从左侧高速飞入,伴随倾斜摆动与淡入 |
layout | Layout.springify() | 兄弟项增删导致位置变化 | 弹性平滑移动到新位置 |
exiting | LightSpeedOutRight | 组件卸载(删除参与者) | 向右高速飞出,伴随倾斜摆动与淡出 |
6.1 退出动画的底层编排
LightSpeedOutRight与进入动画方向相反:初始状态是当前静止位置(translateX: 0、skewX: 0deg、opacity: 1),动画目标是translateX: windowWidth(飞出屏幕右侧)、skewX: -45deg、opacity: 0。从 Lightspeed.ts 的实现可见其编排与LightSpeedInLeft完全对称:
return (values: EntryExitAnimationsValues) => { 'worklet'; return { animations: { opacity: delayFunction(delay, animation(targetValues?.opacity ?? 0, config)), transform: animateTransformToValues( [{ translateX: values.windowWidth }, { skewX: '-45deg' }], targetValues, animationAndConfig, delayFunction, delay ), }, initialValues: { opacity: initialValues?.opacity ?? 1, transform: pickTransformValues( [{ translateX: 0 }, { skewX: '0deg' }], initialValues ), }, }; };值得注意的工程细节:exiting 动画完成后,组件才会真正从视图树中移除。这意味着 React Native 的卸载过程不会打断退出动画,动画播放完毕后再销毁原生视图,从而避免「动画刚播一帧就被卸载」的闪烁问题。
七、完整代码与进阶阅读
将以上四步汇总,即得到完整的动画化列表。数据层完整实现可参考 fullCode.md。整个改造过程仅需三个要点:用Animated.View作为动画载体、为增删改挂载entering/exiting/layout三个属性、用链式修饰器微调动画手感。
如果想进一步深入,当前仓库与文档还提供了以下素材:
- 预设动画全集:进入/退出动画的完整清单与参数说明见 EntryAnimations.md 与 ExitAnimations.md;
- 布局过渡与自定义动画:LayoutTransitions.md 讲解线性、序列、跳跃等过渡方案,CustomAnimations.md 介绍如何手写动画,KeyframeAnimations.md 则支持基于关键帧的编排;
- 概念总览:布局动画的设计动机与适用范围见 layout_animations.md;
- 源码位置:
LightSpeed系列在 defaultAnimations/Lightspeed.ts,线性过渡在 defaultTransitions/LinearTransition.ts,链式修饰器基类在 animationBuilder/ComplexAnimationBuilder.ts。
八、小结
回顾整条改造链路,_participantInternals.md这一节虽然只展示了一个看似普通的View包裹结构,但它承载了布局动画的全部前提:
- 动画必须作用在单一根视图上,才能保证列表项整体进入/退出/移动的一致性;
- 普通组件不具备动画能力,必须换成
Animated.*内置组件或经createAnimatedComponent包装的组件; - 在此基础上,
entering、layout、exiting三个属性分别接管组件的出现、位移与消失,配合springify、duration等链式修饰器即可精细控制动画手感。
通过源码我们可以看到,这些预设动画本质上都是在 UI 线程运行的 worklet:它们读取视图的几何快照(windowWidth、currentOriginX/Y、targetOriginX/Y等),用withTiming、withSpring、withSequence组合出复杂的运动轨迹。理解了这个机制,你不仅会使用现成预设,还能基于 CustomAnimations.md 构建属于自己的布局动画。
【免费下载链接】react-native-reanimatedReact Native's Animated library reimplemented项目地址: https://gitcode.com/GitHub_Trending/re/react-native-reanimated
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考