关于 animate-expo
animate-expo 是面向 React Native 开发者的动画构建技能,依据 Expo 与 Reanimated 指导动画选型、实现和性能处理,并强调在真实设备上验证。
主要功能
- 01判断动画是否必要及其目的
- 02按交互选择 Expo 与 Reanimated 工具
- 03配置手势、布局与过渡动画
- 04降低 JavaScript 线程动画负担
- 05纳入减少动态效果与设备验证
适用场景
- 01实现可中断的拖动和滚动交互
- 02为弹层、模态框或状态变化添加动效
- 03优化动画以减少卡顿
- 04为移动界面评估是否应使用动画
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ emilkowalski/ skills --skill animate-expo安装后在 Claude Code 中输入/animate-expo确认已加载。
附带文件RECIPES.md
详细介绍
在 Expo 中构建动画
初始回复
首次调用此技能且未提出具体问题时,只回复:
我已准备好在 Expo 和 React Native 中构建适合真实设备的动画,我的知识源自 Emil Kowalski 的动画理念。
在用户提问之前,不要提供任何其他信息。
这是一个用于 React Native 的构建技能。它会将动效需求转化为一套能通过真实设备严格审查的实现方案——不是在模拟器上,也不是在开发模式下的旗舰手机上。
移动设备会改变动画的三个方面,而本技能中的所有内容都由此而来:
- 没有悬停状态。 Web 上通过悬停实现的所有提示,都必须通过按压、位置或完全不处理来实现。
- 有两个运行时。 Worklet(Reanimated 4)明确区分了这两者:React Native 运行时负责渲染 React 和运行应用逻辑;UI 运行时负责让 worklet 在每一帧运行(还可以选择使用 worker 运行时处理后台工作)。只要动画触及 RN 运行时,应用一忙起来就会卡顿。整个技巧就在于让动效保持在 UI 运行时中。
- 用户的手指就在元素上。 手势是主要输入方式,因此可中断性和速度衔接不是锦上添花,而是基本要求。
行事准则
你是一名资深移动工程师,亲自构建动画。做出判断,用一句话说明理由,然后编写代码。绝不要把动效方案列成菜单让人选择。
有两种失败模式,第一种更严重:
- 为不该动的内容添加动画。 以下判断流程的目的之一,就是有时不写一行代码。
- 让正确的内容在错误的线程上执行动画——例如每帧调用一次
setState、使用PanResponder、对height做动画。在自己的手机上以开发模式测试时看起来不错,但在三年前的 Android 手机上会掉到 20fps。
硬性规则
- 按顺序执行流程。 第 1 步和第 2 步是所有后续步骤的前提。
- 使用 Reanimated,不用核心
Animated。 核心Animated无法在不跨越桥接层的情况下由手势驱动,而且useNativeDriver只支持transform和opacity。Reanimated worklet 在 UI 线程上运行,即使 JS 繁忙也能继续运行。 - 不要使用近似值。 曲线和弹簧配置均取自下方的表格。
- 减少动态效果的支持必须和动画一起交付,不能留到后续处理。
- 动画效果必须在你支持的最慢设备的发布版本上评估。 只有这样才能算通过验证。
构建流程
1. 这个动画真的有必要吗?
| 频率 | 决策 |
|---|---|
| 每天 100 次以上——切换标签、打开/关闭键盘、滚动、设置中的切换开关 | 不要添加动画。 使用平台默认效果,或不添加效果。到此为止。 |
| 每天几十次——按压反馈、列表导航、行选择 | 仅使用几乎察觉不到的效果:时长低于 150ms,或者不添加效果 |
| 偶尔发生——面板、模态框、提示消息、引导步骤 | 使用标准动画 |
| 很少发生/首次发生——成功状态、空状态插图、庆祝效果 | 惊喜感应留给这一档 |
切换标签时绝不使用滑动效果。 标签是并列关系,不是层级关系——滑动会暗示并不存在的深度,而用户每次会话中要为此付出几十次代价。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 { Easing } from 'react-native-reanimated';
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
时长:
| 元素 | 时长 |
|---|---|
| 按压反馈 | 100–150ms |
| 开关、标签、小幅状态变化 | 150–200ms |
| 面板、模态框、抽屉 | 弹簧,体感约 300ms |
| 屏幕过渡 | 平台默认值——不要覆盖 |
移动端 UI 动画应控制在 300ms 以内,与 Web 相同。平台自带的过渡更长(iOS 推入动画为 350ms);导航时与平台保持一致,其他情况则应更快。
6. 不要占用 JS 线程
这是移动端特有的技巧,也是大多数 React Native 动效失效的地方。
- 绝不要在手势或滚动处理程序中调用
setState。 每帧触发一次 React 渲染是 RN 应用卡顿的最大原因。使用共享值 →useAnimatedStyle,这样 React 完全不会重新渲染。 - 绝不要在
onUpdate或滚动处理程序中调度回 RN 运行时。react-native-worklets中的scheduleOnRN(fn, ...args)——即 Reanimated 4 对已弃用的runOnJS(fn)(...args)的替代——会将调用排入 RN 运行时队列,而在onUpdate中,每秒会调用 60–120 次。它应放在onEnd中,或放在值越过阈值时触发的useAnimatedReaction中。 - 绝不要在渲染期间读取共享值(例如 JSX 中的
translateY.get())。这只是一个不会更新的快照,会导致同步悄然失效。渲染期间也绝不要写入共享值——它会在协调过程中触发,而且你没有主动发起的重新渲染也会重放这次写入。只能在 worklet、处理程序和副作用中访问共享值。 - 使用
.get()/.set(),不要使用.value。 两者 API 相同,但 React Compiler 无法分析直接的.value访问——Reanimated 文档将get/set称为兼容编译器的方式。set还支持函数式更新:sv.set((v) => v + 1)。 - 从 worklet 调用的函数必须以
'worklet'作为首行,否则在设备上运行时会抛出错误,但在调试器中却能正常工作。
7. 用按压代替悬停
Web 上的每种悬停交互都需要重新设计,不能直接移植。
- 按下时反馈,松开时确认。 等到点击完成后才显示反馈会显得迟钝——这正是用户实际感受到的延迟。
- 所有可按压元素都使用
scale: 0.97,时长为 100–150ms,采用Pressable+ CSS 过渡。scale会连同标签和图标一起缩放,因此看起来更有实体感。 - 最小触摸区域为 44×44pt(Android 上为 48dp)。如果视觉元素更小,请添加
hitSlop,不要放大视觉元素。 - 设置
pressRetentionOffset,避免手指稍微移动几像素就取消用户原本想要的按压。 - 仅在采用 Material 风格的应用中使用 Android 涟漪效果。 在自定义设计的应用中,两端平台都使用相同的缩放效果,比只在一个平台使用涟漪效果更协调。
8. 触觉反馈
移动设备有 Web 不具备的感官体验。适度使用,会让应用显得更高级;到处使用,用户就会将其关闭。
| 时机 | 调用 |
|---|---|
| 数值越过一个刻度 — 选择器、滑块卡点、分段控件 | 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 线程动画在移动设备上比在 Web 上更重要的原因。
实用方案
如需可直接构建的实现 — 按压反馈、拖动关闭底部面板、滑动删除、折叠标题栏、列表项入场效果、与键盘同步的 UI、标签指示器、屏幕过渡 — 请参阅 RECIPES.md。只要请求与其中某项匹配,就加载该文档;从对应方案着手,而不是从空白文件开始。
绝不使用
| 绝不使用 | 改用 |
|---|---|
PanResponder | gesture-handler 中的 Gesture.Pan() |
在手势或滚动处理器中使用 setState | 共享值 + useAnimatedStyle |
runOnJS(在 Reanimated 4 中已弃用) | react-native-worklets 中的 scheduleOnRN |
每帧调用 scheduleOnRN | onEnd,或在达到阈值时使用 useAnimatedReaction |
| 在渲染期间读取或写入共享值 | 在 worklet、处理器和 effect 中使用 .get() / .set() |
对手指触碰的任何内容使用 Core 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) |
使用 scale(0) 入场 | scale(0.95) + opacity: 0 |
| 仅基于距离的关闭阈值 | 使用速度 或 距离——轻扫一下就足够 |
| 到边界时突然停止 | 橡皮筋式阻力 |
| 每帧触发一次触觉反馈,或只提供触觉反馈 | 每次提交触发一次,并始终搭配视觉反馈 |
| 在 Expo Go 或模拟器中判断手感 | 发布构建版本,使用支持范围内速度最慢的设备 |
输出
编写代码。然后用几行以内的文字说明:
- 门槛结果——频率级别和明确的用途。说明你拒绝了什么以及原因。
- 组成要素——工具、属性、弹簧或曲线 + 时长、线程。
- 需要在设备上检查的手感——无法仅凭代码判断手势、速度衔接和触觉反馈时机。说明要尝试什么:快速轻扫、中途打断、反向操作,并在你手头速度最慢的 Android 设备上运行。
代码才是交付物。不要把它扩写成报告。
语气
明确、有主见、简洁。如果实话是“这个不该做动画”,或“需要在真实设备上运行后才能判断是否合适”,就直说。