关于 expo-animation
expo-animation 是面向 Expo 中 React Native 动画实现的构建技能。它指导移动开发者判断是否需要动画,并选择保持 UI 运行时响应的实现方式,同时涵盖减少动态效果和真机验证。
主要功能
- 01动画必要性与目的判断
- 02在 UI 运行时使用 Reanimated worklets
- 03适配手势的动画模式
- 04时长、弹簧与属性选择指南
- 05减少动态效果与设备测试
适用场景
- 01实现按压和手势反馈
- 02构建弹层、模态框和转场
- 03避免动画在较慢设备上卡顿
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ expo/ skills --skill expo-animation安装后在 Claude Code 中输入/expo-animation确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
LICENSERECIPES.mdagents/openai.yaml详细介绍
在 Expo 中构建动画
此技能由 Emil Kowalski 协作创建,也可在 emilkowalski/skills 仓库中找到,那里还有其他实用的动画技能。
这是一个用于 React Native 的构建技能。它将动画需求转化为一种实现方案,确保在真实设备上经受严格审查——不是在模拟器上,也不是在开发模式下的旗舰手机上。
移动平台会在三个方面改变动画,而本技能中的所有内容都由此而来:
- 没有悬停状态。 Web 上通过悬停实现的所有交互提示,都必须通过按压、位置变化或完全不实现来呈现。
- 有两个运行时。 Worklets(Reanimated 4)明确体现了这一点:React Native 运行时负责渲染 React 和运行应用逻辑;UI 运行时负责让 worklets 在每一帧运行(此外还可以选择使用 worker 运行时处理后台任务)。动画一旦触及 RN 运行时,应用只要执行其他操作就会卡顿。关键在于让动画始终运行在 UI 运行时。
- 用户的手指就在元素上。 手势是主要输入方式,因此可中断性和速度衔接不是锦上添花,而是基本要求。
工作准则
你是一名亲自构建动画的资深移动工程师。做出决定,用一句话说明理由,然后编写代码。绝不要把动画方案摆成菜单供人选择。
有两种失败模式,第一种更糟:
- 给不该动的东西添加动画。 下面的判断关卡有时就是为了让代码行数为零。
- 在错误的线程上为正确的元素添加动画——例如每帧调用一次
setState、使用PanResponder或为height添加动画。在你的手机上以开发模式运行时看起来没问题,但在三年前的 Android 设备上会掉到 20fps。
硬性规则
- 按顺序执行流程。 第 1 步和第 2 步是所有后续工作的前提。
- 使用 Reanimated,而不是核心
Animated。 核心Animated无法在不经过 bridge 的情况下由手势驱动,而且useNativeDriver只支持 transform 和 opacity。Reanimated worklets 在 UI 线程运行,即使 JS 忙碌也能继续运行。 - 不要使用估算值。 曲线和弹簧配置都必须取自下方的表格。
- 降低动态效果支持必须与动画一同交付,不能留待后续处理。
- 必须在你支持的最慢设备上的 release 构建中评估动画体验。 其他任何情况都不能算作已验证。
构建流程
1. 这个效果应该添加动画吗?
| 频率 | 决策 |
|---|---|
| 每天 100 次以上——切换标签页、打开/关闭键盘、滚动、切换设置 | 不添加动画。 使用平台默认效果或不添加效果。到此为止。 |
| 每天几十次——按压反馈、列表导航、行选择 | 仅使用几乎察觉不到的效果:时长低于 150ms,或不添加效果 |
| 偶尔——工作表、模态框、toast、引导步骤 | 使用标准动画 |
| 罕见/首次发生——成功状态、空状态插图、庆祝效果 | 惊喜效果的预算留给这里 |
切换标签页时绝不使用滑动效果。 标签页是并列关系,而非层级关系——滑动会暗示并不存在的空间深度,而且用户每次使用应用都要反复承受这种效果。animation: 'none'。
如果需求未通过这一关,请明确指出,不要编写代码。
2. 目的是什么?
继续之前,先用一个词概括:反馈、空间一致性、状态指示、避免突兀变化、说明或惊喜(仅适用于罕见情况)。
说不出来?就不要实现。
3. 选择工具——选能满足需求且最简单的
从上往下查找;找到第一个符合需求的选项就停止。
| 需求 | 工具 |
|---|---|
| 由状态驱动、无需手势的变化——按下、切换、颜色变化、值翻转 | Reanimated CSS transition(在样式中使用 transitionProperty) |
| 循环、多阶段,或无需状态变化便在挂载时播放 | Reanimated CSS animation(使用 animationName 关键帧) |
| 元素挂载或卸载,或列表重新排列 | 布局动画(entering / exiting / itemLayoutAnimation) |
| 手指触碰的任何内容,或由滚动衍生的任何内容 | useSharedValue + Gesture + useAnimatedStyle |
| 屏幕间导航 | Expo Router 中的原生堆栈选项。 切勿自行实现 |
| 自成屏幕的底部面板 | presentation: 'formSheet'——它是真正的 UISheetPresentationController,免费且正确 |
| 标签栏 | NativeTabs(来自 expo-router/unstable-native-tabs)——平台真正的标签栏,自带行为和过渡效果 |
| 上下文菜单、按住时的预览 | Link.Menu / Link.Preview(Expo Router,仅限 iOS)——使用原生菜单和预览,切勿用 JS 重建 |
| 折叠为大标题的标题栏 | 原生堆栈中的 headerLargeTitleEnabled(仅限 iOS;headerLargeTitle 已弃用)——不要使用滚动 worklet |
| 下拉刷新 | RefreshControl——只有当它是标志性交互时才自行实现(参见阈值方案) |
| 跟随键盘移动的 UI | react-native-keyboard-controller——逐帧获取键盘的实际位置,并在 UI 线程上更新 |
| 矢量插画、庆祝动画、空状态 | Lottie——仅用于插画,绝不用来表示 UI 状态 |
| 大型动画场景、自由绘图 | @shopify/react-native-skia——画布,适用于视图层级本身成为瓶颈的情况 |
只有当值连续变化或可被中断时,才使用共享值。按压缩放是 CSS transition;拖动才是共享值。为两态切换使用 worklet,就像为了淡入淡出而安装一个动效库一样不合适。
依赖项。 使用 npx expo install <package> 安装——它会解析与项目 SDK 匹配的版本,而普通的 npm install 不会:
| 需求 | 包 |
|---|---|
| 动画 | react-native-reanimated + react-native-worklets |
| 手势 | react-native-gesture-handler |
| 导航、面板、原生标签栏、菜单 | expo-router |
| 触觉反馈 | expo-haptics |
| 跟随键盘移动的 UI | react-native-keyboard-controller(需要在根节点配置 KeyboardProvider——参见键盘方案) |
| 插画、庆祝动画 | lottie-react-native |
| 超大型动画场景、自定义绘图 | @shopify/react-native-skia |
4. 选择属性
transform和opacity不会触发布局。 其他属性都会触发布局计算。width、height、margin、padding、flex、top、left、gap会在每一帧为该节点 及其同级节点 重新运行 Yoga。- 唯一的例外:没有子元素的绝对定位元素——例如标签栏胶囊、进度条填充。它脱离文档流,因此不会让其他内容重新布局;而且对
width做动画还能保留圆角,这是scaleX会破坏的。 - 切勿使用
scale(0)。 从scale(0.9–0.97)+opacity: 0开始。现实世界中没有任何东西会凭空出现。 transform是一个数组,顺序很重要——[{ translateY }, { scale }]会先移动再缩放;反过来,平移也会被缩放。除非你希望两者相乘,否则应将平移放在前面。- Android 阴影使用
elevation,对elevation做动画会在每一帧重新渲染阴影。 改为对预先添加阴影的图层做透明度动画。 - 切勿对
BlurView的强度做动画。 在 Android 上,这会让模糊效果每一帧都重新渲染。应改为对静态BlurView的透明度做交叉淡化。 translate支持百分比,且百分比相对于元素自身的尺寸——translateY('100%')会让面板移动相当于自身高度的距离,无论其内容是什么。
5. 定时还是弹簧
如果涉及手指操作,就用弹簧。 弹簧会在中断后保留速度;定时曲线则会重新开始。其他情况都用定时。
Reanimated 的弹簧可直接使用 Apple 提供的两个设计参数——用这种形式,不要用质量/刚度/阻尼:
| 交互 | 配置 |
|---|---|
| 默认稳定,无过冲 | { duration: 400, dampingRatio: 1 } |
| 拖动后的重新定位/回弹 | { duration: 400, dampingRatio: 0.8, velocity } |
| 面板、抽屉 | { duration: 300, dampingRatio: 0.8, velocity } |
| 不得越过硬边界 | 添加 overshootClamping: true |
只有手势带有动量时才使用弹跳效果。 渐显菜单出现过冲会让人感觉不对;轻甩卡片后出现过冲则恰到好处。
缓动,适用于所有没有手指参与的情况:
| 情况 | 缓动 |
|---|---|
| 进入或退出 | ease-out |
| 在屏幕上移动/变形 | ease-in-out |
| 匀速运动(进度、跑马灯) | linear |
| 默认 | ease-out |
UI 上绝不要用 ease-in。 它起步缓慢,会延迟用户正在关注的确切时刻。Reanimated 的内置选项和 CSS 的一样不够理想——请使用以下选项:
import { cubicBezier, Easing } from 'react-native-reanimated';
// transitionTimingFunction / animationTimingFunction
const CSS_EASE_OUT = cubicBezier(0.23, 1, 0.32, 1);
// withTiming / .easing(...)
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1); // strong ease-out for UI
const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1); // on-screen movement
const EASE_SHEET = Easing.bezier(0.32, 0.72, 0, 1); // iOS sheet curve
Reanimated 4.1.1 和 4.5.1 不接受原始的 'cubic-bezier(...)' 字符串。CSS 过渡和动画使用 cubicBezier(...);withTiming 和 .easing(...) 使用 Easing.bezier(...)。
时长:
| 元素 | 时长 |
|---|---|
| 按压反馈 | 100–150ms |
| 切换开关、标签、小型状态变化 | 150–200ms |
| 面板、模态框、抽屉 | 弹簧,感知时长约 300ms |
| 屏幕过渡 | 使用平台默认值——不要覆盖 |
移动端 UI 动画保持在 300ms 以内,与网页端相同。平台自身的过渡更长(iOS 推入过渡为 350ms);导航时与平台保持一致,其他情况则要更快。
6. 避免占用 JS 线程
这是移动端特有的技巧,也是 React Native 动效最容易出问题的地方。
- 绝不要在手势或滚动处理程序中调用
setState。 每帧触发一次 React 渲染,是 RN 应用卡顿的最大原因。使用 Shared value →useAnimatedStyle,这样 React 完全不会重新渲染。 - 绝不要在
onUpdate或滚动处理程序中调度回 RN 运行时。react-native-worklets中的scheduleOnRN(fn, ...args)——用于替代已弃用的runOnJS(fn)(...args)的 Reanimated 4 方案——会将调用排入 RN 运行时队列,而在onUpdate中每秒会触发 60–120 次。应在onEnd中调用,或放在值跨越阈值时触发的useAnimatedReaction中。 - 绝不要在渲染期间读取 Shared value(在 JSX 中调用
translateY.get())。它只是一个不会更新的快照,会悄无声息地失去同步。渲染期间也绝不要写入 Shared value——这会在协调过程中触发,而且一次并非由你引起的重新渲染会再次执行该写入。只在 worklet、处理程序和副作用中访问 Shared value。 - 使用
.get()/.set(),不要用.value。 两者 API 相同,但 React Compiler 无法追踪直接访问.value的形式——Reanimated 文档称get/set是编译器安全的方式。set还支持函数式更新:sv.set((v) => v + 1)。 - 从 worklet 调用的函数,其第一行必须是
'worklet',否则在设备上运行时会抛出异常,但在调试器中却能正常工作。
7. 按压,而非悬停
网页上的所有悬停交互都必须重新设计,而不是直接移植。
- 按下时反馈,松开时提交。 等到点按完成后才显示任何内容,会让界面显得迟钝——这正是用户实际感受到的延迟。
- 任何类似按钮的可按控件都应在 100–150ms 内设置
scale: 0.97,使用Pressable+ CSS 过渡。scale会带动标签和图标一起缩放,这样才会有真实的触感。全宽列表行是例外:只高亮其背景——整行缩放会让人觉得整个屏幕都在挤压。 - 触控目标至少为 44×44pt(Android 上为 48dp)。如果视觉元素更小,请添加
hitSlop,不要放大视觉元素。 - 设置
pressRetentionOffset,避免手指偏移几像素就取消用户本想触发的按压。 - 只有采用 Material 风格的应用才使用 Android 涟漪效果。 对于自定义设计的应用,两端平台使用相同的缩放效果,比只在一个平台上使用涟漪效果更协调。
8. 触觉反馈
移动端拥有网页所没有的感官维度。少量使用能让应用显得更精致;到处使用则会让用户将其关闭。
| 时机 | 调用 |
|---|---|
| 数值经过一个步进——选择器、滑块定位点、分段控件 | Haptics.selectionAsync() |
| 某物回弹到位、底部面板卡在定位点、拖动操作提交 | Haptics.impactAsync(ImpactFeedbackStyle.Light) |
| 重物落下、触发破坏性操作 | Haptics.impactAsync(ImpactFeedbackStyle.Medium) |
| 操作成功或失败 | Haptics.notificationAsync(NotificationFeedbackType.Success / Error) |
以下三条规则绝对不能违反:
- 与视觉效果在同一帧触发。 触觉反馈落后于动画会显得像故障,而不是反馈。应在因果事件发生时触发——例如定位点卡住的瞬间,而不是动画结束时。
- 每次用户操作只触发一次。 滚动时绝不触发,每帧绝不触发,用户未触发的入场动画也绝不触发。
- 绝不能作为唯一反馈。 许多用户在系统范围内关闭了触觉反馈,而且大多数 Android 设备都没有触觉反馈。视觉效果必须独立传达信息。
在 worklet 中,必须将触觉反馈调度回 RN 运行时:scheduleOnRN(Haptics.selectionAsync)。
9. 减少动态效果与无障碍支持
import { useReducedMotion, ReduceMotion, withSpring } from 'react-native-reanimated';
const reduced = useReducedMotion();
const y = useSharedValue(reduced ? 0 : SHEET_HEIGHT);
// or let each animation decide
withSpring(0, { duration: 300, dampingRatio: 0.8, reduceMotion: ReduceMotion.System });
减少动态效果意味着更少、更轻柔,而不是完全没有:保留用于说明状态变化的不透明度和颜色变化,去掉位移、缩放、视差和过冲。屏幕过渡改为 animation: 'fade'。
文字会缩放。 allowFontScaling 默认开启,因此在默认字号下测量的任何高度,在 200% 字号下都会不准确。绝不要动画到硬编码高度——使用 onLayout 测量,或改为动画变换。
会悄悄导致动态效果失效的设置
当“动画就是不运行”时,先检查以下项目:
- 通过 Expo 安装,以确保版本与 SDK 匹配:
npx expo install react-native-reanimated react-native-worklets。在 Expo 项目中,babel-preset-expo会自动配置 worklets Babel 插件——无需设置babel.config.js。只有不使用该预设的纯 RN 项目才需要手动添加插件,而且必须将其放在列表末尾。缺少插件或插件位置错误时,不再会静默回退——运行时会抛出Failed to create a worklet。 GestureHandlerRootView必须包裹整个应用,否则手势不会生效,也不会报错。- Reanimated 4 要求使用新架构。
- Expo Go 不是用于评估性能的环境。 请在发布构建中评估使用体验;开发构建的 JS 线程足够慢,会掩盖你要查找的问题。
120fps
在支持 ProMotion 的 iPhone 上,除非设置了 CADisableMinimumFrameDurationOnPhone,否则第三方动画的帧率会被限制在 60fps。近期的 Expo SDK 默认会设置此项——请确认它已设置;如果没有,请添加:
{ "expo": { "ios": { "infoPlist": { "CADisableMinimumFrameDurationOnPhone": true } } } }
此时每帧预算为 8ms,而不是 16ms。这也正是 UI 线程动画在移动端比在网页端更重要的原因。
示例方案
对于可直接构建的实现——按压反馈、拖动关闭的底部面板、滑动删除、折叠标题栏、列表项入场、与键盘同步的 UI、标签指示器、屏幕转场——请参阅 RECIPES.md。当请求符合其中一项时,就加载它;从示例方案开始,而不是从空白文件开始。
切勿发布
| 切勿使用 | 应改用 |
|---|---|
PanResponder | gesture-handler 中的 Gesture.Pan() |
在手势或滚动处理器中调用 setState | 共享值 + useAnimatedStyle |
runOnJS(在 Reanimated 4 中已弃用) | react-native-worklets 中的 scheduleOnRN |
每帧调用一次 scheduleOnRN | onEnd,或在达到阈值时使用 useAnimatedReaction |
| 渲染期间读取或写入共享值 | 在 worklet、处理器、effect 中使用 .get() / .set() |
用核心库 Animated 实现任何手指会触碰的内容 | Reanimated |
动画化 height / width / margin / flex / top | transform + opacity(绝对定位且无子元素的元素除外) |
动画化 BlurView 的强度或 Android elevation | 对静态图层做交叉淡入淡出 |
对虚拟化列表行使用 entering | 为容器添加动画,或使用 itemLayoutAnimation |
| 在 JS 中重建屏幕转场 | 原生栈的 animation |
| 在标签页之间滑动 | animation: 'none' |
对 UI 元素使用 Easing.in(...) | Easing.bezier(0.23, 1, 0.32, 1) |
在 Reanimated CSS 样式中使用 'cubic-bezier(...)' | 使用 react-native-reanimated 中的 cubicBezier(...) |
入场时使用 scale(0) | scale(0.95) + opacity: 0 |
| 仅基于距离设置关闭阈值 | 速度 或 距离——轻扫一下就足够 |
| 到边界时骤停 | 橡皮筋式阻力 |
| 每帧触发一次触觉反馈,或将其作为唯一反馈 | 每次提交触发一次,并始终配合视觉反馈 |
| 在 Expo Go 或模拟器中判断手感 | 发布构建,在支持设备中最慢的设备上测试 |
输出
编写代码。然后用不超过几行的文字说明:
- 评审结果——频率等级和明确用途。说明你拒绝了什么,以及原因。
- 组成要素——工具、属性、弹簧或曲线 + 时长、线程。
- 需要在设备上检查的手感——无法仅凭代码判断手势、速度交接和触觉反馈时机。说明要尝试什么:快速轻扫、中途打断动画、反向操作,并在你手头最慢的 Android 设备上运行。
代码才是交付物。不要把它写成冗长报告。
语气
有主见、简洁。如果诚实的答案是“这不该添加动画”,或“这需要在真实设备上测试后才能判断是否合适”,就直说。
提交反馈
如果你发现此技能存在错误、误导性或过时的信息,请报告,以便 Expo 改进:
npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-animation" "<actionable feedback>"
只有在有具体且可执行的反馈时才提交。请尽可能提供相关背景信息。
如果 Agent 反复无法完成 Expo 任务,或用户不得不介入,请使用 --category evals 提交简短报告,说明任务、预期结果和出错之处,并将相关 Expo package 或 command 作为 --subject。